From f72994689a9caf369291ce170a55412955f9e31d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:21:03 -0500 Subject: [PATCH 001/105] Initial commit --- .../workflows/backstage-catalog-helper.yml | 36 ++++++++++++++ .github/workflows/dependency-review.yml | 16 +++++++ .github/workflows/export_github_data.yml | 25 ++++++++++ .github/workflows/labels.yml | 14 ++++++ .github/workflows/ossf-scorecard.yml | 47 +++++++++++++++++++ .github/workflows/s3-backup.yml | 39 +++++++++++++++ .github/workflows/scripts/run-shellcheck.sh | 3 ++ .github/workflows/shellcheck.yml | 17 +++++++ LICENSE | 21 +++++++++ README.md | 10 ++++ 10 files changed, 228 insertions(+) create mode 100644 .github/workflows/backstage-catalog-helper.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/export_github_data.yml create mode 100644 .github/workflows/labels.yml create mode 100644 .github/workflows/ossf-scorecard.yml create mode 100644 .github/workflows/s3-backup.yml create mode 100755 .github/workflows/scripts/run-shellcheck.sh create mode 100644 .github/workflows/shellcheck.yml create mode 100644 LICENSE create mode 100644 README.md diff --git a/.github/workflows/backstage-catalog-helper.yml b/.github/workflows/backstage-catalog-helper.yml new file mode 100644 index 00000000..1695cecf --- /dev/null +++ b/.github/workflows/backstage-catalog-helper.yml @@ -0,0 +1,36 @@ +name: Backstage Catalog Info Helper +on: + workflow_dispatch: + +jobs: + update-catalog-info: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + with: + fetch-depth: 0 + - name: Run Backstage Catalog Info Helper + uses: cds-snc/backstage-catalog-info-helper-action@v0.3.1 + with: + github_app_id: ${{ secrets.SRE_BOT_RW_APP_ID }} + github_app_private_key: ${{ secrets.SRE_BOT_RW_PRIVATE_KEY }} + github_organization: cds-snc + - name: impersonate Read/Write GH App + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a + id: generate_token + with: + app_id: ${{ secrets.SRE_BOT_RW_APP_ID }} + private_key: ${{ secrets.SRE_BOT_RW_PRIVATE_KEY }} + - name: Create pull request + uses: peter-evans/create-pull-request@6cd32fd93684475c31847837f87bb135d40a2b79 # v7.0.3 + with: + token: ${{ steps.generate_token.outputs.token}} + sign-commits: true + commit-message: 'Add catalog-info.yaml' + branch: 'backstage/catalog-info' + title: 'Add catalog-info.yaml' + body: 'Adding a basic catalog-info.yaml to start populating the backstage catalog with your components.' + labels: 'backstage' + add-paths: | + catalog-info.yaml \ No newline at end of file diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..02384c91 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,16 @@ +name: Dependency review +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Dependency review + uses: actions/dependency-review-action@v1 \ No newline at end of file diff --git a/.github/workflows/export_github_data.yml b/.github/workflows/export_github_data.yml new file mode 100644 index 00000000..fe9bd969 --- /dev/null +++ b/.github/workflows/export_github_data.yml @@ -0,0 +1,25 @@ +name: GitHub repository metadata exporter +on: + workflow_dispatch: + schedule: + - cron: "20 7 * * *" + +jobs: + export-data: + runs-on: ubuntu-latest + steps: + - name: Audit DNS requests + uses: cds-snc/dns-proxy-action@main + env: + DNS_PROXY_FORWARDTOSENTINEL: "true" + DNS_PROXY_LOGANALYTICSWORKSPACEID: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + DNS_PROXY_LOGANALYTICSSHAREDKEY: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Export Data + uses: cds-snc/github-repository-metadata-exporter@main + with: + github-app-id: ${{ secrets.SRE_BOT_RO_APP_ID }} + github-app-installation-id: ${{ secrets.SRE_BOT_RO_INSTALLATION_ID }} + github-app-private-key: ${{ secrets.SRE_BOT_RO_PRIVATE_KEY }} + log-analytics-workspace-id: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + log-analytics-workspace-key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 00000000..7c8da4ed --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,14 @@ +name: Sync repository labels +on: + issues: + pull_request: + workflow_dispatch: + +jobs: + sync-labels: + runs-on: ubuntu-latest + name: Sync repository labels + steps: + - uses: cds-snc/labels@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml new file mode 100644 index 00000000..501997e8 --- /dev/null +++ b/.github/workflows/ossf-scorecard.yml @@ -0,0 +1,47 @@ +name: Scorecards supply-chain security +on: + workflow_dispatch: + schedule: + # Weekly on Saturdays. + - cron: "30 1 * * 6" + push: + branches: + - main + +permissions: read-all + +jobs: + analysis: + name: Scorecards analysis + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + + steps: + - name: "Checkout code" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@169c9b9248e36d400bebded8160c7fe2cbbc7762 + with: + results_file: ossf-results.json + results_format: json + publish_results: false + + - name: "Add metadata" + run: | + full_repo="${{ github.repository }}" + OWNER=${full_repo%/*} + REPO=${full_repo#*/} + jq -c '. + {"metadata_owner": "'$OWNER'", "metadata_repo": "'$REPO'", "metadata_query": "ossf"}' ossf-results.json > ossf-results-modified.json + + - name: "Post results to Sentinel" + uses: cds-snc/sentinel-forward-data-action@main + with: + file_name: ossf-results-modified.json + log_type: GitHubMetadata_OSSF_Scorecard + log_analytics_workspace_id: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + log_analytics_workspace_key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} diff --git a/.github/workflows/s3-backup.yml b/.github/workflows/s3-backup.yml new file mode 100644 index 00000000..09801b11 --- /dev/null +++ b/.github/workflows/s3-backup.yml @@ -0,0 +1,39 @@ +name: S3 backup +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * *" + +jobs: + s3-backup: + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # retrieve all history + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_S3_BACKUP_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_S3_BACKUP_SECRET_ACCESS_KEY }} + aws-region: ca-central-1 + + - name: Create ZIP bundle + run: | + ZIP_FILE=`basename ${{ github.repository }}`-`date '+%Y-%m-%d'`.zip + zip -rq "${ZIP_FILE}" . + mkdir -p ${{ github.repository }} + mv "${ZIP_FILE}" ${{ github.repository }} + + - name: Upload to S3 bucket + run: | + aws s3 sync . s3://${{ secrets.AWS_S3_BACKUP_BUCKET }} --exclude='*' --include='${{ github.repository }}/*' + + - name: Notify Slack channel if this job failed + if: ${{ failure() }} + run: | + json='{"text":"S3 backup failed in !"}' + curl -X POST -H 'Content-type: application/json' --data "$json" ${{ secrets.SLACK_NOTIFY_WEBHOOK }} diff --git a/.github/workflows/scripts/run-shellcheck.sh b/.github/workflows/scripts/run-shellcheck.sh new file mode 100755 index 00000000..df6643fe --- /dev/null +++ b/.github/workflows/scripts/run-shellcheck.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:v0.7.1 -P ./bin/ -x ./.github/workflows/scripts/*.sh diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 00000000..188ac134 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,17 @@ +name: Shellcheck +on: + push: + paths: + - "**/*.sh" + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Shellcheck + run: | + .github/workflows/scripts/run-shellcheck.sh + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a8a69f4c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Canadian Digital Service – Service numérique canadien + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..44263e81 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Generic Project Template + +This repository provides some base files for setting up a repository at +CDS. Plan is to create more project template for specific technologies: + +- project-template-terraform +- project-template-python +- project-template-nodejs + +Note that default community health files are maintained at https://github.com/cds-snc/.github From a687fe65de6f97b532f5f42a1c18cf668d488daa Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 24 Nov 2025 12:38:47 -0500 Subject: [PATCH 002/105] chore: migrate source code --- .gitignore | 32 + Makefile | 42 + README.md | 41 +- docker/Dockerfile | 8 + docker/docker-compose.yml | 19 + pom.xml | 221 ++++ scripts/decrypt.sh | 8 + .../ca/gc/tbs/PageSuccessApplication.java | 47 + .../java/ca/gc/tbs/config/CacheConfig.java | 19 + .../java/ca/gc/tbs/config/CachePreloader.java | 38 + .../ca/gc/tbs/config/ConfigProperties.java | 35 + ...CustomizeAuthenticationSuccessHandler.java | 55 + .../java/ca/gc/tbs/config/PageConfig.java | 56 + .../ca/gc/tbs/config/WebSecurityConfig.java | 99 ++ .../ca/gc/tbs/controller/AuthController.java | 118 ++ .../tbs/controller/CustomErrorController.java | 21 + .../tbs/controller/DashboardController.java | 1021 +++++++++++++++ .../gc/tbs/controller/ImportController.java | 78 ++ .../ca/gc/tbs/controller/LoginController.java | 98 ++ .../gc/tbs/controller/ProblemController.java | 1097 ++++++++++++++++ .../gc/tbs/controller/ReportController.java | 106 ++ .../gc/tbs/controller/TopTaskController.java | 1124 +++++++++++++++++ .../ca/gc/tbs/controller/UserController.java | 145 +++ .../ca/gc/tbs/domain/OriginalProblem.java | 6 + src/main/java/ca/gc/tbs/domain/Problem.java | 273 ++++ src/main/java/ca/gc/tbs/domain/Role.java | 29 + .../java/ca/gc/tbs/domain/TopTaskSurvey.java | 391 ++++++ src/main/java/ca/gc/tbs/domain/User.java | 76 ++ .../java/ca/gc/tbs/filter/LanguageFilter.java | 78 ++ .../repository/CustomTopTaskRepository.java | 9 + .../CustomTopTaskRepositoryImpl.java | 36 + .../repository/OriginalProblemRepository.java | 6 + .../gc/tbs/repository/ProblemRepository.java | 134 ++ .../ca/gc/tbs/repository/RoleRepository.java | 9 + .../gc/tbs/repository/TopTaskRepository.java | 34 + .../ca/gc/tbs/repository/UserRepository.java | 13 + .../tbs/repository/UserRepositoryCustom.java | 7 + .../repository/UserRepositoryCustomImpl.java | 19 + .../java/ca/gc/tbs/security/JWTFilter.java | 71 ++ src/main/java/ca/gc/tbs/security/JWTUtil.java | 76 ++ src/main/java/ca/gc/tbs/service/BadWords.java | 114 ++ .../ca/gc/tbs/service/ContentService.java | 174 +++ .../java/ca/gc/tbs/service/EmailService.java | 80 ++ .../gc/tbs/service/ErrorKeywordService.java | 142 +++ .../gc/tbs/service/ProblemCacheService.java | 38 + .../ca/gc/tbs/service/ProblemDateService.java | 66 + .../java/ca/gc/tbs/service/UserService.java | 151 +++ src/main/resources/application.properties.gpg | Bin 0 -> 604 bytes src/main/resources/messages.properties | 1 + src/main/resources/messages_fr.properties | 1 + .../static/badwords/allowed_words.txt | 2 + .../resources/static/badwords/badwords_en.txt | 1 + .../resources/static/badwords/badwords_fr.txt | 1 + .../resources/static/badwords/threats_en.txt | 1 + .../resources/static/badwords/threats_fr.txt | 1 + .../resources/static/css/loading-overlay.css | 323 +++++ .../css/pageFeedbackDashboard_styles.css | 411 ++++++ .../static/css/pageFeedback_styles.css | 308 +++++ src/main/resources/static/css/style.css | 62 + .../static/css/topTaskSurvey_styles.css | 412 ++++++ .../error_keywords/errors_bilingual.txt | 7 + .../static/error_keywords/errors_en.txt | 208 +++ .../static/error_keywords/errors_fr.txt | 134 ++ .../resources/static/js/loading-overlay.js | 325 +++++ .../static/js/pageFeedback-dashboard.js | 506 ++++++++ src/main/resources/static/js/pageFeedback.js | 390 ++++++ .../static/js/spring-friendly.min.js | 33 + src/main/resources/static/js/taskList.js | 414 ++++++ src/main/resources/static/js/topTaskSurvey.js | 524 ++++++++ src/main/resources/static/menu/admin_en.html | 21 + src/main/resources/static/menu/admin_fr.html | 21 + src/main/resources/static/menu/user_en.html | 20 + src/main/resources/static/menu/user_fr.html | 20 + .../static/secrets/notification.secret | 1 + src/main/resources/templates/default_en.html | 258 ++++ src/main/resources/templates/default_fr.html | 259 ++++ .../templates/department_datalist.html | 576 +++++++++ src/main/resources/templates/error_en.html | 11 + src/main/resources/templates/error_fr.html | 14 + src/main/resources/templates/login_en.html | 27 + src/main/resources/templates/login_fr.html | 28 + .../templates/pageFeedbackDashboard_en.html | 242 ++++ .../templates/pageFeedbackDashboard_fr.html | 243 ++++ .../resources/templates/pageFeedback_en.html | 226 ++++ .../resources/templates/pageFeedback_fr.html | 226 ++++ src/main/resources/templates/reports.html | 29 + src/main/resources/templates/signup_en.html | 64 + src/main/resources/templates/signup_fr.html | 64 + src/main/resources/templates/success_en.html | 14 + src/main/resources/templates/success_fr.html | 14 + .../resources/templates/topTaskSurvey_en.html | 211 ++++ .../resources/templates/topTaskSurvey_fr.html | 215 ++++ src/main/resources/templates/users_en.html | 94 ++ src/main/resources/templates/users_fr.html | 94 ++ src/main/resources/templates/wide_en.html | 258 ++++ src/main/resources/templates/wide_fr.html | 259 ++++ 96 files changed, 14127 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 pom.xml create mode 100755 scripts/decrypt.sh create mode 100644 src/main/java/ca/gc/tbs/PageSuccessApplication.java create mode 100644 src/main/java/ca/gc/tbs/config/CacheConfig.java create mode 100644 src/main/java/ca/gc/tbs/config/CachePreloader.java create mode 100644 src/main/java/ca/gc/tbs/config/ConfigProperties.java create mode 100644 src/main/java/ca/gc/tbs/config/CustomizeAuthenticationSuccessHandler.java create mode 100644 src/main/java/ca/gc/tbs/config/PageConfig.java create mode 100644 src/main/java/ca/gc/tbs/config/WebSecurityConfig.java create mode 100644 src/main/java/ca/gc/tbs/controller/AuthController.java create mode 100644 src/main/java/ca/gc/tbs/controller/CustomErrorController.java create mode 100644 src/main/java/ca/gc/tbs/controller/DashboardController.java create mode 100644 src/main/java/ca/gc/tbs/controller/ImportController.java create mode 100644 src/main/java/ca/gc/tbs/controller/LoginController.java create mode 100644 src/main/java/ca/gc/tbs/controller/ProblemController.java create mode 100644 src/main/java/ca/gc/tbs/controller/ReportController.java create mode 100644 src/main/java/ca/gc/tbs/controller/TopTaskController.java create mode 100644 src/main/java/ca/gc/tbs/controller/UserController.java create mode 100644 src/main/java/ca/gc/tbs/domain/OriginalProblem.java create mode 100644 src/main/java/ca/gc/tbs/domain/Problem.java create mode 100644 src/main/java/ca/gc/tbs/domain/Role.java create mode 100644 src/main/java/ca/gc/tbs/domain/TopTaskSurvey.java create mode 100644 src/main/java/ca/gc/tbs/domain/User.java create mode 100644 src/main/java/ca/gc/tbs/filter/LanguageFilter.java create mode 100644 src/main/java/ca/gc/tbs/repository/CustomTopTaskRepository.java create mode 100644 src/main/java/ca/gc/tbs/repository/CustomTopTaskRepositoryImpl.java create mode 100644 src/main/java/ca/gc/tbs/repository/OriginalProblemRepository.java create mode 100644 src/main/java/ca/gc/tbs/repository/ProblemRepository.java create mode 100644 src/main/java/ca/gc/tbs/repository/RoleRepository.java create mode 100644 src/main/java/ca/gc/tbs/repository/TopTaskRepository.java create mode 100644 src/main/java/ca/gc/tbs/repository/UserRepository.java create mode 100644 src/main/java/ca/gc/tbs/repository/UserRepositoryCustom.java create mode 100644 src/main/java/ca/gc/tbs/repository/UserRepositoryCustomImpl.java create mode 100644 src/main/java/ca/gc/tbs/security/JWTFilter.java create mode 100644 src/main/java/ca/gc/tbs/security/JWTUtil.java create mode 100644 src/main/java/ca/gc/tbs/service/BadWords.java create mode 100644 src/main/java/ca/gc/tbs/service/ContentService.java create mode 100644 src/main/java/ca/gc/tbs/service/EmailService.java create mode 100644 src/main/java/ca/gc/tbs/service/ErrorKeywordService.java create mode 100644 src/main/java/ca/gc/tbs/service/ProblemCacheService.java create mode 100644 src/main/java/ca/gc/tbs/service/ProblemDateService.java create mode 100644 src/main/java/ca/gc/tbs/service/UserService.java create mode 100644 src/main/resources/application.properties.gpg create mode 100644 src/main/resources/messages.properties create mode 100644 src/main/resources/messages_fr.properties create mode 100644 src/main/resources/static/badwords/allowed_words.txt create mode 100644 src/main/resources/static/badwords/badwords_en.txt create mode 100644 src/main/resources/static/badwords/badwords_fr.txt create mode 100644 src/main/resources/static/badwords/threats_en.txt create mode 100644 src/main/resources/static/badwords/threats_fr.txt create mode 100644 src/main/resources/static/css/loading-overlay.css create mode 100644 src/main/resources/static/css/pageFeedbackDashboard_styles.css create mode 100644 src/main/resources/static/css/pageFeedback_styles.css create mode 100644 src/main/resources/static/css/style.css create mode 100644 src/main/resources/static/css/topTaskSurvey_styles.css create mode 100644 src/main/resources/static/error_keywords/errors_bilingual.txt create mode 100644 src/main/resources/static/error_keywords/errors_en.txt create mode 100644 src/main/resources/static/error_keywords/errors_fr.txt create mode 100644 src/main/resources/static/js/loading-overlay.js create mode 100644 src/main/resources/static/js/pageFeedback-dashboard.js create mode 100644 src/main/resources/static/js/pageFeedback.js create mode 100644 src/main/resources/static/js/spring-friendly.min.js create mode 100644 src/main/resources/static/js/taskList.js create mode 100644 src/main/resources/static/js/topTaskSurvey.js create mode 100644 src/main/resources/static/menu/admin_en.html create mode 100644 src/main/resources/static/menu/admin_fr.html create mode 100644 src/main/resources/static/menu/user_en.html create mode 100644 src/main/resources/static/menu/user_fr.html create mode 100644 src/main/resources/static/secrets/notification.secret create mode 100644 src/main/resources/templates/default_en.html create mode 100644 src/main/resources/templates/default_fr.html create mode 100644 src/main/resources/templates/department_datalist.html create mode 100644 src/main/resources/templates/error_en.html create mode 100644 src/main/resources/templates/error_fr.html create mode 100644 src/main/resources/templates/login_en.html create mode 100644 src/main/resources/templates/login_fr.html create mode 100644 src/main/resources/templates/pageFeedbackDashboard_en.html create mode 100644 src/main/resources/templates/pageFeedbackDashboard_fr.html create mode 100644 src/main/resources/templates/pageFeedback_en.html create mode 100644 src/main/resources/templates/pageFeedback_fr.html create mode 100644 src/main/resources/templates/reports.html create mode 100644 src/main/resources/templates/signup_en.html create mode 100644 src/main/resources/templates/signup_fr.html create mode 100644 src/main/resources/templates/success_en.html create mode 100644 src/main/resources/templates/success_fr.html create mode 100644 src/main/resources/templates/topTaskSurvey_en.html create mode 100644 src/main/resources/templates/topTaskSurvey_fr.html create mode 100644 src/main/resources/templates/users_en.html create mode 100644 src/main/resources/templates/users_fr.html create mode 100644 src/main/resources/templates/wide_en.html create mode 100644 src/main/resources/templates/wide_fr.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4259aca0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# Spring Boot files +**/application.properties +**/application-local.properties + +# System files +.DS_Store + +# IDE files +.settings/ +.project +.classpath +*.iml +.idea/ +.vscode/ + +# Kubernetes secrets +kubernetes/feedback-viewer-ingress.yml + +# Other +google-java-format-*.jar \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..dd2d4609 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Java formatter Makefile +# Keep this file locally, don't commit it to the repository + +# Variables +FORMATTER_VERSION = 1.17.0 +FORMATTER_JAR = google-java-format-$(FORMATTER_VERSION)-all-deps.jar +FORMATTER_URL = https://github.com/google/google-java-format/releases/download/v$(FORMATTER_VERSION)/$(FORMATTER_JAR) +JAVA_FILES = $(shell find src -name "*.java") + +# Default target +.PHONY: help +help: + @echo "Available targets:" + @echo " format - Format all Java files using Google Java Format" + @echo " check - Check if files need formatting without changing them" + @echo " clean - Remove the formatter jar" + @echo "" + @echo "Note: Keep this Makefile locally, don't commit it to your repository" + +# Download the formatter if it doesn't exist +$(FORMATTER_JAR): + @echo "Downloading Google Java Format..." + @curl -L $(FORMATTER_URL) -o $(FORMATTER_JAR) + +# Format all Java files +.PHONY: format +format: $(FORMATTER_JAR) + @echo "Formatting Java files..." + @java -jar $(FORMATTER_JAR) --replace $(JAVA_FILES) + @echo "Formatting complete!" + +# Check if files need formatting without changing them +.PHONY: check +check: $(FORMATTER_JAR) + @echo "Checking Java files for formatting issues..." + @java -jar $(FORMATTER_JAR) --dry-run --set-exit-if-changed $(JAVA_FILES) + +# Clean up +.PHONY: clean +clean: + @echo "Removing formatter jar..." + @rm -f $(FORMATTER_JAR) \ No newline at end of file diff --git a/README.md b/README.md index 44263e81..0f7d41fd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,37 @@ -# Generic Project Template +# Feedback Viewer -This repository provides some base files for setting up a repository at -CDS. Plan is to create more project template for specific technologies: +This is a web application for viewing and managing feedback data submitted by users. It is built with Spring Boot Java on the backend, and uses MongoDB to store the data. The frontend is implemented with HTML, CSS, and JavaScript. -- project-template-terraform -- project-template-python -- project-template-nodejs +## Features -Note that default community health files are maintained at https://github.com/cds-snc/.github +The Feedback Viewer provides the following features: + +- **View feedback**: Users can browse the list of feedback entries, which includes information such as a comment, Page URL, institution, and more. +- **Filter feedback**: Users can filter the feedback entries based on various criteria, such as date range, section, theme, and institution. +- **Export feedback**: Users can export the feedback entries to a CSV or Excel file for further analysis. + +## Installation + +To install and run the Feedback Viewer, follow these steps: + +1. Clone the repository: `git clone https://github.com/alpha-canada-ca/feedback-viewer.git` +2. Install the required dependencies: `./mvnw install` +3. Start the application: `./mvnw spring-boot:run` + +The application should now be accessible at `http://localhost:8080`. + +## Configuration + +The Feedback Viewer can be configured by modifying the following properties in the `application.properties` file: + +- `spring.data.mongodb.uri`: The connection string for the MongoDB database. + +## Contributing + +Contributions to the Feedback Viewer are welcome! To contribute, follow these steps: + +1. Fork the repository. +2. Create a new branch for your changes: `git checkout -b my-feature-branch`. +3. Make your changes and commit them: `git commit -m "Add new feature"`. +4. Push your changes to your fork: `git push origin my-feature-branch`. +5. Submit a pull request to the main repository. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..dcf02c9d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,8 @@ +FROM maven:3.8.3-jdk-8 +RUN rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y \ + # add any necessary packages here + && apt-get clean +RUN mkdir -p /app +ADD target/PageSuccess-0.0.1-SNAPSHOT.jar /app/app.jar +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..2621a91f --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3' +services: + mongodb: + image: mongo:latest + ports: + - 27017:27017 + volumes: + - mongodb_data_container:/data/db + pagesuccess: + build: . + working_dir: /app + ports: + - 8181:8080 + - 8000:8000 + command: mvn -X clean spring-boot:run + depends_on: + - mongodb +volumes: + mongodb_data_container: diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..0b6ccb46 --- /dev/null +++ b/pom.xml @@ -0,0 +1,221 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.2.1.RELEASE + + + ca.gc.tbs + PageSuccess + 0.0.1-SNAPSHOT + PageSuccess + View all comments about success of a page + + + 1.8 + + + + + + false + + bintray-gov-uk-notify-maven + bintray + https://dl.bintray.com/gov-uk-notify/maven + + + + + + + false + + bintray-gov-uk-notify-maven + bintray-plugins + https://dl.bintray.com/gov-uk-notify/maven + + + + + + com.github.darrachequesne + spring-data-mongodb-datatables + 1.0.3 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + + net.sourceforge.nekohtml + nekohtml + 1.9.21 + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + org.apache.poi + poi + 4.1.2 + + + org.apache.poi + poi-ooxml + 4.1.2 + + + + org.springframework.boot + spring-boot-starter-security + + + org.dom4j + dom4j + 2.1.3 + + + jaxen + jaxen + + + nz.net.ultraq.thymeleaf + thymeleaf-layout-dialect + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity4 + 3.0.4.RELEASE + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + org.apache.commons + commons-csv + 1.8 + + + uk.gov.service.notify + notifications-java-client + 3.15.1-RELEASE + pom + + + uk.gov.service.notify + notifications-java-client + 3.15.1-RELEASE + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + com.auth0 + java-jwt + 3.18.2 + + + io.jsonwebtoken + jjwt-api + 0.10.7 + + + io.jsonwebtoken + jjwt-impl + 0.10.7 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.10.7 + runtime + + + org.springframework.boot + spring-boot-starter-cache + + + + + edu.stanford.nlp + stanford-corenlp + 4.4.0 + + + + edu.stanford.nlp + stanford-corenlp + 4.4.0 + models + + + + + + org.springframework.boot + spring-boot-starter-logging + + + org.apache.logging.log4j + log4j-api + compile + + + org.apache.logging.log4j + log4j-core + compile + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/scripts/decrypt.sh b/scripts/decrypt.sh new file mode 100755 index 00000000..2466ef0f --- /dev/null +++ b/scripts/decrypt.sh @@ -0,0 +1,8 @@ +#!/bin/sh +export GPG_TTY=$(tty) +gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ +--output ./src/main/resources/application.properties ./src/main/resources/application.properties.gpg +ls ./src/main/resources +gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ +--output kubernetes/feedback-viewer-ingress.yml kubernetes/feedback-viewer-ingress.yml.gpg +ls kubernetes diff --git a/src/main/java/ca/gc/tbs/PageSuccessApplication.java b/src/main/java/ca/gc/tbs/PageSuccessApplication.java new file mode 100644 index 00000000..4c4c5f5b --- /dev/null +++ b/src/main/java/ca/gc/tbs/PageSuccessApplication.java @@ -0,0 +1,47 @@ +package ca.gc.tbs; + +import ca.gc.tbs.domain.Role; +import ca.gc.tbs.repository.RoleRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.datatables.DataTablesRepositoryFactoryBean; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +// + +@SpringBootApplication +@EnableMongoRepositories(repositoryFactoryBeanClass = DataTablesRepositoryFactoryBean.class) +public class PageSuccessApplication { + + public static void main(String[] args) { + SpringApplication.run(PageSuccessApplication.class, args); + } + + @Bean + CommandLineRunner init(RoleRepository roleRepository) { + + return args -> { + Role adminRole = roleRepository.findByRole("ADMIN"); + if (adminRole == null) { + Role newAdminRole = new Role(); + newAdminRole.setRole("ADMIN"); + roleRepository.save(newAdminRole); + } + + Role userRole = roleRepository.findByRole("USER"); + if (userRole == null) { + Role newUserRole = new Role(); + newUserRole.setRole("USER"); + roleRepository.save(newUserRole); + } + Role apiRole = roleRepository.findByRole("API"); + if (apiRole == null) { + Role newApiRole = new Role(); + newApiRole.setRole("API"); + roleRepository.save(newApiRole); + } + }; + } +} diff --git a/src/main/java/ca/gc/tbs/config/CacheConfig.java b/src/main/java/ca/gc/tbs/config/CacheConfig.java new file mode 100644 index 00000000..4ece8635 --- /dev/null +++ b/src/main/java/ca/gc/tbs/config/CacheConfig.java @@ -0,0 +1,19 @@ +package ca.gc.tbs.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager("problemDates", "distinctUrls", "processedProblems"); + } +} diff --git a/src/main/java/ca/gc/tbs/config/CachePreloader.java b/src/main/java/ca/gc/tbs/config/CachePreloader.java new file mode 100644 index 00000000..eb4bb6a7 --- /dev/null +++ b/src/main/java/ca/gc/tbs/config/CachePreloader.java @@ -0,0 +1,38 @@ +package ca.gc.tbs.config; + +import ca.gc.tbs.service.ProblemCacheService; +import ca.gc.tbs.service.ProblemDateService; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Component +public class CachePreloader { + + private static final Logger LOGGER = LoggerFactory.getLogger(CachePreloader.class); + + private final ProblemCacheService problemCacheService; + private final ProblemDateService problemDateService; + + public CachePreloader(ProblemCacheService problemCacheService, ProblemDateService problemDateService) { + this.problemCacheService = problemCacheService; + this.problemDateService = problemDateService; + } + + @EventListener(ApplicationStartedEvent.class) + public void preloadCaches() { + LOGGER.info("Starting cache preloading process..."); + LOGGER.info("Preloading distinct URLs cache..."); + problemCacheService.getDistinctProcessedUrlsForCache(); + LOGGER.info("Distinct URLs cache preloaded."); + LOGGER.info("Preloading processed problems cache..."); + problemCacheService.getProcessedProblems(); + LOGGER.info("Processed problems cache preloaded."); + LOGGER.info("Preloading problem dates cache..."); + problemDateService.getProblemDates(); + LOGGER.info("Problem dates cache preloaded."); + LOGGER.info("All caches preloaded successfully."); + } +} diff --git a/src/main/java/ca/gc/tbs/config/ConfigProperties.java b/src/main/java/ca/gc/tbs/config/ConfigProperties.java new file mode 100644 index 00000000..f00c260e --- /dev/null +++ b/src/main/java/ca/gc/tbs/config/ConfigProperties.java @@ -0,0 +1,35 @@ +package ca.gc.tbs.config; + +import java.util.Locale; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; +import org.springframework.web.servlet.i18n.SessionLocaleResolver; + +@Configuration +@ConfigurationProperties(prefix = "internationalization") +public class ConfigProperties implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()); + } + + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor lci = new LocaleChangeInterceptor(); + lci.setParamName("lang"); + return lci; + } + + @Bean + public LocaleResolver localeResolver() { + SessionLocaleResolver slr = new SessionLocaleResolver(); + slr.setDefaultLocale(Locale.CANADA); + return slr; + } +} diff --git a/src/main/java/ca/gc/tbs/config/CustomizeAuthenticationSuccessHandler.java b/src/main/java/ca/gc/tbs/config/CustomizeAuthenticationSuccessHandler.java new file mode 100644 index 00000000..b71aa6de --- /dev/null +++ b/src/main/java/ca/gc/tbs/config/CustomizeAuthenticationSuccessHandler.java @@ -0,0 +1,55 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package ca.gc.tbs.config; + +import java.io.IOException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.savedrequest.SavedRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; + +@Component +public class CustomizeAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private RequestCache requestCache = new HttpSessionRequestCache(); + + @Override + @GetMapping(value = "/login") + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + + SavedRequest savedRequest = requestCache.getRequest(request, response); + + if (savedRequest == null || savedRequest.getRedirectUrl().contains("signin")) { + for (GrantedAuthority auth : authentication.getAuthorities()) { + if ("ADMIN".equals(auth.getAuthority())) { + response.sendRedirect("/u/index"); + } else { + response.sendRedirect("/pageFeedback"); + } + } + return; + } + clearAuthenticationAttributes(request); + + // Use the DefaultSavedRequest URL + String targetUrl = savedRequest.getRedirectUrl(); + logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + public void setRequestCache(RequestCache requestCache) { + this.requestCache = requestCache; + } +} diff --git a/src/main/java/ca/gc/tbs/config/PageConfig.java b/src/main/java/ca/gc/tbs/config/PageConfig.java new file mode 100644 index 00000000..8890413d --- /dev/null +++ b/src/main/java/ca/gc/tbs/config/PageConfig.java @@ -0,0 +1,56 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package ca.gc.tbs.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +@Configuration +public class PageConfig implements WebMvcConfigurer { + + @Value("${pagesuccess.pythonScriptPath}") + private String pythonScriptPath; + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + return bCryptPasswordEncoder; + } + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/home").setViewName("home"); + registry.addViewController("/").setViewName("home"); + registry.addViewController("/dashboard").setViewName("dashboard"); + registry.addViewController("/login").setViewName("login"); + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry + .addResourceHandler("/static/**") // « /static/css/myStatic.css + .addResourceLocations("classpath:/static/") // Default Static Loaction + .setCachePeriod(3600) + .resourceChain(true) // 4.1 + .addResolver(new PathResourceResolver()); // 4.1 + + // src/main/resources/templates/static/... + registry + .addResourceHandler("/templates/**") // « /templates/style.css + .addResourceLocations("classpath:/templates/static/"); + + // File located on disk + registry + .addResourceHandler("/python/**") + .addResourceLocations("file://" + this.pythonScriptPath); + } +} diff --git a/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java b/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java new file mode 100644 index 00000000..978b7186 --- /dev/null +++ b/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java @@ -0,0 +1,99 @@ +package ca.gc.tbs.config; // package ca.gc.tbs.config; + +import ca.gc.tbs.security.JWTFilter; +import ca.gc.tbs.service.UserService; +import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.thymeleaf.extras.springsecurity4.dialect.SpringSecurityDialect; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; + + @Autowired CustomizeAuthenticationSuccessHandler customizeAuthenticationSuccessHandler; + + @Autowired private UserService myUserDetailsService; + + @Autowired private JWTFilter jwtFilter; + + @Override + protected void configure(AuthenticationManagerBuilder auth) throws Exception { + UserService userDetailsService = myUserDetailsService; + auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.csrf() + .disable() + .authorizeRequests() + .antMatchers("/createApiUser") + .hasAuthority("ADMIN") + .antMatchers("/authenticate") + .permitAll() + .antMatchers("/api/user/**") + .hasRole("USER") + .antMatchers("/", "/checkExists", "/error", "/enableAdmin", "/login", "/signup", "/success") + .permitAll() + .antMatchers("/u/**") + .hasAnyAuthority("ADMIN") + .antMatchers("/python/**", "/reports/**", "/dashboard/**") + .hasAnyAuthority("USER", "ADMIN") + .anyRequest() + .authenticated() + .and() + .formLogin() + .loginPage("/login") + .permitAll() + .successHandler(customizeAuthenticationSuccessHandler) + .failureUrl("/login?error=true") + .usernameParameter("email") + .passwordParameter("password") + .and() + .logout() + .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) + .logoutSuccessUrl("/login?logout=true") + .and() + .exceptionHandling() + .authenticationEntryPoint( + (request, response, authException) -> { + if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + } else { + response.sendRedirect("/login"); + } + }); + + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + } + + @Override + public void configure(WebSecurity web) throws Exception { + web.ignoring() + .antMatchers("/resources/**", "/static/**", "/css/**", "/js/**", "/images/**", "/**/*.js"); + } + + @Bean + public SpringSecurityDialect springSecurityDialect() { + return new SpringSecurityDialect(); + } + + @Bean + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } +} diff --git a/src/main/java/ca/gc/tbs/controller/AuthController.java b/src/main/java/ca/gc/tbs/controller/AuthController.java new file mode 100644 index 00000000..9378476c --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/AuthController.java @@ -0,0 +1,118 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.Role; +import ca.gc.tbs.domain.User; +import ca.gc.tbs.security.JWTUtil; +import ca.gc.tbs.service.UserService; +import java.util.Arrays; +import java.util.HashSet; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@RestController +public class AuthController { + + @Autowired private AuthenticationManager authenticationManager; + + @Autowired private JWTUtil jwtUtil; + + @Autowired private UserService userService; + + @PostMapping("/authenticate") + public ResponseEntity createAuthenticationToken(@RequestBody AuthRequest authRequest) { + try { + Authentication authentication = + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + authRequest.getUsername(), authRequest.getPassword())); + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + + // Check if the user is an admin + if (!userService.isAdmin(userService.findUserByEmail(userDetails.getUsername())) + && !userService.isAPI(userService.findUserByEmail(userDetails.getUsername()))) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Access denied. Only API users & Admins can generate tokens."); + } + + String token = jwtUtil.generateToken(userDetails); + return ResponseEntity.ok(token); + } catch (BadCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid username or password"); + } + } + + @GetMapping("/createApiUser") + public ResponseEntity createApiUser( + @RequestParam String username, @RequestParam String password) { + // Check if user already exists + User existingUser = userService.findUserByEmail(username); + if (existingUser != null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("User already exists."); + } + + User user = new User(); + user.setEmail(username); + user.setPassword(password); + user.setEnabled(true); + + Role apiRole = userService.findRoleByName("API"); + if (apiRole == null) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("API role does not exist."); + } + user.setRoles(new HashSet<>(Arrays.asList(apiRole))); + + userService.saveApiUser(user); + return ResponseEntity.ok("API user created successfully."); + } + + static class CreateUserRequest { + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + } + + // Request body for authentication + static class AuthRequest { + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + } +} diff --git a/src/main/java/ca/gc/tbs/controller/CustomErrorController.java b/src/main/java/ca/gc/tbs/controller/CustomErrorController.java new file mode 100644 index 00000000..304a1ecd --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/CustomErrorController.java @@ -0,0 +1,21 @@ +package ca.gc.tbs.controller; + +import javax.servlet.http.HttpServletRequest; +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +public class CustomErrorController implements ErrorController { + + @RequestMapping("/error") + public String handleError(HttpServletRequest request) { + String lang = (String) request.getSession().getAttribute("lang"); + return "error_" + lang; + } + + @Override + public String getErrorPath() { + return "/error"; + } +} diff --git a/src/main/java/ca/gc/tbs/controller/DashboardController.java b/src/main/java/ca/gc/tbs/controller/DashboardController.java new file mode 100644 index 00000000..576e6980 --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/DashboardController.java @@ -0,0 +1,1021 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.Problem; +import ca.gc.tbs.repository.ProblemRepository; +import ca.gc.tbs.service.ErrorKeywordService; +import ca.gc.tbs.service.ProblemCacheService; +import ca.gc.tbs.service.ProblemDateService; +import ca.gc.tbs.service.UserService; +import org.bson.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.*; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.datatables.DataTablesInput; +import org.springframework.data.mongodb.datatables.DataTablesOutput; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +@Controller +public class DashboardController { + private static final Logger LOGGER = LoggerFactory.getLogger(DashboardController.class); + + @Autowired + private ProblemRepository problemRepository; + private static final boolean ASC = true; + private static final boolean DESC = false; + @Autowired + private ProblemDateService problemDateService; + @Autowired + private ProblemCacheService problemCacheService; + private int totalComments = 0; + private int totalPages = 0; + + private List problems; + + @Autowired + private UserService userService; + @Autowired + private ErrorKeywordService errorKeywordService; + @Autowired + private MongoTemplate mongoTemplate; + + private static final Map> institutionMappings = new HashMap<>(); + private static final Map> sectionMappings = new HashMap<>(); + + + static { + // Initialize section mappings + sectionMappings.put("disability", Arrays.asList("disability", "disability benefits")); + sectionMappings.put("news", Arrays.asList("news")); + + // Initialize institution mappings + institutionMappings.put( + "AAFC", + Arrays.asList( + "AAFC", + "AAC", + "AGRICULTURE AND AGRI-FOOD CANADA", + "AGRICULTURE ET AGROALIMENTAIRE CANADA", + "AAFC/AAC")); + institutionMappings.put( + "ACOA", + Arrays.asList( + "ACOA", + "APECA", + "ATLANTIC CANADA OPPORTUNITIES AGENCY", + "AGENCE DE PROMOTION ÉCONOMIQUE DU CANADA ATLANTIQUE", + "ACOA/APECA")); + institutionMappings.put( + "ATSSC", + Arrays.asList( + "ATSSC", + "SCDATA", + "ADMINISTRATIVE TRIBUNALS SUPPORT SERVICE OF CANADA", + "SERVICE CANADIEN D’APPUI AUX TRIBUNAUX ADMINISTRATIFS", + "ATSSC/SCDATA")); + institutionMappings.put( + "CANNOR", + Arrays.asList( + "CANNOR", + "RNCAN", + "CANADIAN NORTHERN ECONOMIC DEVELOPMENT AGENCY", + "AGENCE CANADIENNE DE DÉVELOPPEMENT ÉCONOMIQUE DU NORD", + "CANNOR/RNCAN")); + institutionMappings.put( + "CATSA", + Arrays.asList( + "CATSA", + "ACSTA", + "CANADIAN AIR TRANSPORT SECURITY AUTHORITY", + "ADMINISTRATION CANADIENNE DE LA SÛRETÉ DU TRANSPORT AÉRIEN", + "CATSA/ACSTA")); + institutionMappings.put( + "CBSA", + Arrays.asList( + "CBSA", + "ASFC", + "CANADA BORDER SERVICES AGENCY", + "AGENCE DES SERVICES FRONTALIERS DU CANADA", + "CBSA/ASFC")); + institutionMappings.put( + "CCG", + Arrays.asList("CCG", "GCC", "CANADIAN COAST GUARD", "GARDE CÔTIÈRE CANADIENNE", "CCG/GCC")); + institutionMappings.put( + "CER", + Arrays.asList( + "CER", "REC", "CANADA ENERGY REGULATOR", "RÉGIE DE L'ÉNERGIE DU CANADA", "CER/REC")); + institutionMappings.put( + "CFIA", + Arrays.asList( + "CFIA", + "ACIA", + "CANADIAN FOOD INSPECTION AGENCY", + "AGENCE CANADIENNE D’INSPECTION DES ALIMENTS", + "CFIA/ACIA")); + institutionMappings.put( + "CIHR", + Arrays.asList( + "CIHR", + "IRSC", + "CANADIAN INSTITUTES OF HEALTH RESEARCH", + "INSTITUTS DE RECHERCHE EN SANTÉ DU CANADA", + "CIHR/IRSC")); + institutionMappings.put( + "CIPO", + Arrays.asList( + "CIPO", + "OPIC", + "CANADIAN INTELLECTUAL PROPERTY OFFICE", + "OFFICE DE LA PROPRIÉTÉ INTELLECTUELLE DU CANADA", + "CIPO/OPIC")); + institutionMappings.put( + "CIRNAC", + Arrays.asList( + "CIRNAC", + "RCAANC", + "CROWN-INDIGENOUS RELATIONS AND NORTHERN AFFAIRS CANADA", + "RELATIONS COURONNE-AUTOCHTONES ET AFFAIRES DU NORD CANADA", + "CIRNAC/RCAANC")); + institutionMappings.put( + "CRA", + Arrays.asList( + "CRA", "ARC", "CANADA REVENUE AGENCY", "AGENCE DU REVENU DU CANADA", "CRA/ARC")); + institutionMappings.put( + "CRTC", + Arrays.asList( + "CRTC", + "CRTC", + "CANADIAN RADIO-TELEVISION AND TELECOMMUNICATIONS COMMISSION", + "CONSEIL DE LA RADIODIFFUSION ET DES TÉLÉCOMMUNICATIONS CANADIENNES")); + institutionMappings.put( + "CSA", + Arrays.asList( + "CSA", "ASC", "CANADIAN SPACE AGENCY", "AGENCE SPATIALE CANADIENNE", "CSA/ASC")); + institutionMappings.put( + "CSC", + Arrays.asList( + "CSC", + "SCC", + "CORRECTIONAL SERVICE CANADA", + "SERVICE CORRECTIONNEL CANADA", + "CSC/SCC")); + institutionMappings.put( + "CSE", + Arrays.asList( + "CSE", + "CST", + "COMMUNICATIONS SECURITY ESTABLISHMENT", + "CENTRE DE LA SÉCURITÉ DES TÉLÉCOMMUNICATIONS", + "CSE/CST")); + institutionMappings.put( + "CSEC", + Arrays.asList( + "CSEC", + "CSTC", + "COMMUNICATIONS SECURITY ESTABLISHMENT CANADA", + "CENTRE DE LA SÉCURITÉ DES TÉLÉCOMMUNICATIONS CANADA", + "CSEC/CSTC")); + institutionMappings.put( + "CSPS", + Arrays.asList( + "CSPS", + "EFPC", + "CANADA SCHOOL OF PUBLIC SERVICE", + "ÉCOLE DE LA FONCTION PUBLIQUE DU CANADA", + "CSPS/EFPC")); + institutionMappings.put( + "DFO", + Arrays.asList( + "DFO", "MPO", "FISHERIES AND OCEANS CANADA", "PÊCHES ET OCÉANS CANADA", "DFO/MPO", "GOVERNMENT OF CANADA, FISHERIES AND OCEANS CANADA, COMMUNICATIONS BRANCH")); + institutionMappings.put( + "DND", Arrays.asList("DND", "MDN", "NATIONAL DEFENCE", "DÉFENSE NATIONALE", "DND/MDN")); + institutionMappings.put( + "ECCC", + Arrays.asList( + "ECCC", + "ENVIRONMENT AND CLIMATE CHANGE CANADA", + "ENVIRONNEMENT ET CHANGEMENT CLIMATIQUE CANADA", + "ECCC")); + institutionMappings.put( + "ESDC", + Arrays.asList( + "ESDC", + "EDSC", + "EMPLOYMENT AND SOCIAL DEVELOPMENT CANADA", + "EMPLOI ET DÉVELOPPEMENT SOCIAL CANADA", + "ESDC/EDSC", + "EMPLOI ET DÉVÉLOPPEMENT SOCIALE CANADA")); + institutionMappings.put( + "FCAC", + Arrays.asList( + "FCAC", + "ACFC", + "FINANCIAL CONSUMER AGENCY OF CANADA", + "AGENCE DE LA CONSOMMATION EN MATIÈRE FINANCIÈRE DU CANADA", + "FCAC/ACFC")); + institutionMappings.put( + "FIN", + Arrays.asList( + "FIN", + "FIN", + "FINANCE CANADA", + "MINISTÈRE DES FINANCES CANADA", + "DEPARTMENT OF FINANCE CANADA", + "GOVERNMENT OF CANADA, DEPARTMENT OF FINANCE", + "MINISTÈRE DES FINANCES", + "FIN")); + institutionMappings.put( + "GAC", + Arrays.asList( + "GAC", "AMC", "GLOBAL AFFAIRS CANADA", "AFFAIRES MONDIALES CANADA", "GAC/AMC")); + institutionMappings.put( + "HC", Arrays.asList("HC", "SC", "HEALTH CANADA", "SANTÉ CANADA", "HC/SC")); + institutionMappings.put( + "INFC", Arrays.asList("INFC", "INFC", "INFRASTRUCTURE CANADA", "INFRASTRUCTURE CANADA")); + institutionMappings.put( + "IOGC", + Arrays.asList( + "IOGC", + "BPGI", + "INDIAN OIL AND GAS CANADA", + "BUREAU DU PÉTROLE ET DU GAZ DES INDIENS", + "IOGC/BPGI")); + institutionMappings.put( + "IRCC", + Arrays.asList( + "IRCC", + "IRCC", + "IMMIGRATION, REFUGEES AND CITIZENSHIP CANADA", + "IMMIGRATION, RÉFUGIÉS ET CITOYENNETÉ CANADA")); + institutionMappings.put( + "ISC", + Arrays.asList( + "ISC", + "SAC", + "INDIGENOUS SERVICES CANADA", + "SERVICES AUX AUTOCHTONES CANADA", + "ISC/SAC")); + institutionMappings.put( + "ISED", + Arrays.asList( + "ISED", + "ISDE", + "INNOVATION, SCIENCE AND ECONOMIC DEVELOPMENT CANADA", + "INNOVATION, SCIENCES ET DÉVELOPPEMENT ÉCONOMIQUE CANADA", + "ISED/ISDE")); + institutionMappings.put( + "JUS", Arrays.asList("JUS", "JUSTICE CANADA", "MINISTÈRE DE LA JUSTICE CANADA", "JUS")); + institutionMappings.put( + "LAC", + Arrays.asList( + "LAC", + "BAC", + "LIBRARY AND ARCHIVES CANADA", + "BIBLIOTHÈQUE ET ARCHIVES CANADA", + "LAC/BAC")); + institutionMappings.put( + "NFB", + Arrays.asList("NFB", "ONF", "NATIONAL FILM BOARD", "OFFICE NATIONAL DU FILM", "NFB/ONF")); + institutionMappings.put( + "NRC", + Arrays.asList( + "NRC", + "CNRC", + "NATIONAL RESEARCH COUNCIL", + "CONSEIL NATIONAL DE RECHERCHES CANADA", + "NRC/CNRC")); + institutionMappings.put( + "NRCAN", + Arrays.asList( + "NRCAN", + "RNCAN", + "NATURAL RESOURCES CANADA", + "RESSOURCES NATURELLES CANADA", + "NRCAN/RNCAN")); + institutionMappings.put( + "NSERC", + Arrays.asList( + "NSERC", + "CRSNG", + "NATURAL SCIENCES AND ENGINEERING RESEARCH CANADA", + "CONSEIL DE RECHERCHES EN SCIENCES NATURELLES ET EN GÉNIE DU CANADA", + "NSERC/CRSNG")); + institutionMappings.put( + "OMBDNDCAF", + Arrays.asList( + "OMBDNDCAF", + "OMBMDNFAC", + "DND/CAF OMBUDSMAN", + "OMBUDSMAN DU MDN/FAC", + "OFFICE OF THE NATIONAL DEFENCE AND CANADIAN ARMED FORCES OMBUDSMAN", + "BUREAU DE L'OMBUDSMAN DE LA DÉFENSE NATIONALE ET DES FORCES ARMÉES CANADIENNES", + "OMBDNDCAF/OMBMDNFAC")); + institutionMappings.put( + "OSB", + Arrays.asList( + "OSB", + "BSF", + "SUPERINTENDENT OF BANKRUPTCY CANADA", + "BUREAU DU SURINTENDANT DES FAILLITES CANADA", + "OSB/BSF")); + institutionMappings.put( + "PBC", + Arrays.asList( + "PBC", + "CLCC", + "PAROLE BOARD OF CANADA", + "COMMISSION DES LIBÉRATIONS CONDITIONNELLES DU CANADA", + "PBC/CLCC")); + institutionMappings.put("PC", Arrays.asList("PC", "PC", "PARCS CANADA", "PARKS CANADA")); + institutionMappings.put( + "PCH", Arrays.asList("PCH", "PCH", "CANADIAN HERITAGE", "PATRIMOINE CANADIEN")); + institutionMappings.put( + "PCO", + Arrays.asList("PCO", "BCP", "PRIVY COUNCIL OFFICE", "BUREAU DU CONSEIL PRIVÉ", "PCO/BCP")); + institutionMappings.put( + "PHAC", + Arrays.asList( + "PHAC", + "ASPC", + "PUBLIC HEALTH AGENCY OF CANADA", + "AGENCE DE LA SANTÉ PUBLIQUE DU CANADA", + "PHAC/ASPC")); + institutionMappings.put( + "PS", + Arrays.asList("PS", "SP", "PUBLIC SAFETY CANADA", "SÉCURITÉ PUBLIQUE CANADA", "PS/SP")); + institutionMappings.put( + "PSC", + Arrays.asList( + "PSC", + "CFP", + "PUBLIC SERVICE COMMISSION OF CANADA", + "COMMISSION DE LA FONCTION PUBLIQUE DU CANADA", + "PSC/CFP")); + institutionMappings.put( + "PSPC", + Arrays.asList( + "PSPC", + "SPAC", + "PUBLIC SERVICES AND PROCUREMENT CANADA", + "SERVICES PUBLICS ET APPROVISIONNEMENT CANADA", + "GOUVERNEMENT DU CANADA, SERVICES PUBLICS ET APPROVISIONNEMENT CANADA", + "GOVERNMENT OF CANADA, PUBLIC SERVICES AND PROCUREMENT CANADA", + "PSPC/SPAC")); + institutionMappings.put( + "RCMP", + Arrays.asList( + "RCMP", + "GRC", + "ROYAL CANADIAN MOUNTED POLICE", + "GENDARMERIE ROYALE DU CANADA", + "RCMP/GRC")); + institutionMappings.put( + "SC", Arrays.asList("SC", "SC", "SERVICE CANADA", "SERVICE CANADA", "SC/SC")); + institutionMappings.put( + "SSC", + Arrays.asList( + "SSC", "PSC", "SHARED SERVICES CANADA", "SERVICES PARTAGÉS CANADA", "SSC/PSC")); + institutionMappings.put( + "SSHRC", + Arrays.asList( + "SSHRC", + "CRSH", + "SOCIAL SCIENCES AND HUMANITIES RESEARCH COUNCIL", + "CONSEIL DE RECHERCHES EN SCIENCES HUMAINES", + "SSHRC/CRSH")); + institutionMappings.put( + "SST", + Arrays.asList( + "SST", + "TSS", + "SOCIAL SECURITY TRIBUNAL OF CANADA", + "TRIBUNAL DE LA SÉCURITÉ SOCIALE DU CANADA", + "SST/TSS")); + institutionMappings.put("STATCAN", Arrays.asList("STATCAN", "STATISTIQUE CANADA")); + institutionMappings.put( + "TBS", + Arrays.asList( + "TBS", + "SCT", + "TREASURY BOARD OF CANADA SECRETARIAT", + "SECRÉTARIAT DU CONSEIL DU TRÉSOR DU CANADA", + "TBS/SCT")); + institutionMappings.put( + "TC", Arrays.asList("TC", "TC", "TRANSPORT CANADA", "TRANSPORTS CANADA")); + institutionMappings.put( + "VAC", + Arrays.asList( + "VAC", "ACC", "VETERANS AFFAIRS CANADA", "ANCIENS COMBATTANTS CANADA", "VAC/ACC")); + institutionMappings.put( + "WAGE", + Arrays.asList( + "WAGE", + "FEGC", + "WOMEN AND GENDER EQUALITY CANADA", + "FEMMES ET ÉGALITÉ DES GENRES CANADA", + "WAGE/FEGC")); + institutionMappings.put( + "WD", + Arrays.asList( + "WD", + "DEO", + "WESTERN ECONOMIC DIVERSIFICATION CANADA", + "DIVERSIFICATION DE L’ÉCONOMIE DE L’OUEST CANADA", + "WD/DEO")); + } + + @RequestMapping(value = "/pageFeedback/totalCommentsCount") + @ResponseBody + public String totalCommentsCount() { + return String.valueOf(totalComments); + } + + @RequestMapping(value = "/pageFeedback/totalPagesCount") + @ResponseBody + public String totalPagesCount() { + return String.valueOf(totalPages); + } + + @GetMapping(value = "/dashboard") + public ModelAndView pageFeedback(HttpServletRequest request) throws Exception { + ModelAndView mav = new ModelAndView(); + String lang = (String) request.getSession().getAttribute("lang"); + mav.addObject("lang", lang); + Map dateMap = problemDateService.getProblemDates(); + if (dateMap != null) { + mav.addObject("earliestDate", dateMap.get("earliestDate")); + LocalDate latestDate = + LocalDate.parse(dateMap.get("latestDate"), DateTimeFormatter.ISO_LOCAL_DATE); + LocalDate previousDate = latestDate.minusDays(1); + String modifiedLatestDate = previousDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + mav.addObject("latestDate", modifiedLatestDate); + } else { + mav.addObject("earliestDate", "N/A"); + mav.addObject("latestDate", "N/A"); + } + + mav.setViewName("pageFeedbackDashboard_" + lang); + return mav; + } + + @GetMapping(value = "/chartData") + @ResponseBody + public List> commentsByDate(HttpServletRequest request) { + Boolean error_keyword = "true".equals(request.getParameter("error_keyword")); + String comments = request.getParameter("comments"); + String startDate = request.getParameter("startDate"); + String endDate = request.getParameter("endDate"); + String theme = request.getParameter("theme"); + String section = request.getParameter("section"); + String language = request.getParameter("language"); + String url = request.getParameter("url"); + String department = request.getParameter("department"); + + Criteria criteria = buildFilterCriteria(startDate, endDate, theme, section, language, url, department); + + List regexCriteria = new ArrayList<>(); + + if (error_keyword) { + Set keywordsToCheck = new HashSet<>(); + keywordsToCheck.addAll(errorKeywordService.getEnglishKeywords()); + keywordsToCheck.addAll(errorKeywordService.getFrenchKeywords()); + keywordsToCheck.addAll(errorKeywordService.getBilingualKeywords()); + + if (!keywordsToCheck.isEmpty()) { + String combinedRegex = keywordsToCheck.stream() + .map(Pattern::quote) + .collect(Collectors.joining("|")); + regexCriteria.add(Criteria.where("problemDetails").regex(combinedRegex, "i")); + //criteria.and("problemDetails").regex(combinedRegex, "i"); + } + + } + // Comment filter, if set + if (comments != null && !comments.trim().isEmpty() && !"null".equalsIgnoreCase(comments.trim())) { + String escapedComment = escapeSpecialRegexCharacters(comments.trim()); + regexCriteria.add(Criteria.where("problemDetails").regex(escapedComment, "i")); + } + + Criteria finalCriteria; + if (!regexCriteria.isEmpty()) { + List ands = new ArrayList<>(); + ands.add(criteria); + ands.addAll(regexCriteria); + finalCriteria = new Criteria().andOperator(ands.toArray(new Criteria[0])); + } else { + finalCriteria = criteria; + } + boolean useDatabase = error_keyword || (comments != null && !comments.trim().isEmpty() && !"null".equalsIgnoreCase(comments.trim())); + if (useDatabase) { + // MongoDB aggregation by problemDate + GroupOperation groupByDate = Aggregation.group("problemDate").count().as("comments"); + SortOperation sortByDate = Aggregation.sort(Sort.Direction.ASC, "_id"); + Aggregation agg = Aggregation.newAggregation( + Aggregation.match(finalCriteria), + groupByDate, + sortByDate + ); + AggregationResults aggResults = mongoTemplate.aggregate(agg, "problem", Document.class); + + // Build dailyCommentsList + List> dailyCommentsList = new ArrayList<>(); + for (Document doc : aggResults) { + Map map = new HashMap<>(); + map.put("date", doc.getString("_id")); // group by "problemDate" + map.put("comments", doc.getInteger("comments", 0)); + dailyCommentsList.add(map); + } + return dailyCommentsList; + } + + if (problems == null) { + return new ArrayList<>(); + } + Map dateToCommentCountMap = new HashMap<>(); + + // Sort problems by date in ascending order + problems.sort(Comparator.comparing(Problem::getProblemDate)); + for (Problem problem : problems) { + if (problem != null && problem.getProblemDate() != null) { + String date = problem.getProblemDate(); + Integer urlEntries = problem.getUrlEntries(); + // Update the count for the given date + dateToCommentCountMap.merge(date, urlEntries, Integer::sum); + } + } + // Convert the map to a list of maps + List> dailyCommentsList = new ArrayList<>(); + dateToCommentCountMap.forEach( + (date, count) -> { + Map dateComments = new HashMap<>(); + dateComments.put("date", date); + dateComments.put("comments", count); + dailyCommentsList.add(dateComments); + }); + + // Sort the list by date in ascending order + dailyCommentsList.sort(Comparator.comparing(map -> (String) map.get("date"))); + + return dailyCommentsList; + } + + + + @Scheduled(cron = "0 1 0 * * *") + @EventListener(ApplicationReadyEvent.class) + public void init() { + LOGGER.info("DashboardController: Starting initial data fetch and cache population."); + problemCacheService.getProcessedProblems(); + problemDateService.getProblemDates(); + LOGGER.info("DashboardController: Initial data fetch and cache population complete."); + } + + @GetMapping(value = "/dashboardData") + @ResponseBody + public DataTablesOutput getDashboardData( + @Valid DataTablesInput input, HttpServletRequest request) { + long beforeUsedMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + String pageLang = (String) request.getSession().getAttribute("lang"); + String department = request.getParameter("department"); + String startDate = request.getParameter("startDate"); + String endDate = request.getParameter("endDate"); + String language = request.getParameter("language"); + String url = request.getParameter("url"); + String comments = request.getParameter("comments"); + String section = request.getParameter("section"); + String theme = request.getParameter("theme"); + Boolean error_keyword = "true".equals(request.getParameter("error_keyword")); + + //error keyword filtering + if (error_keyword) { + + Criteria criteria = buildFilterCriteria(startDate, endDate, theme, section, language, url, department); + + List regexCriteria = new ArrayList<>();//added for combined regex + + // Build regex pattern from all keywords + Set keywordsToCheck = new HashSet<>(); + keywordsToCheck.addAll(errorKeywordService.getEnglishKeywords()); + keywordsToCheck.addAll(errorKeywordService.getFrenchKeywords()); + keywordsToCheck.addAll(errorKeywordService.getBilingualKeywords()); + + + if (!keywordsToCheck.isEmpty()) { + regexCriteria.add(Criteria.where("problemDetails").regex(String.join("|", keywordsToCheck), "i")); + } + // Comment filter regex + if (comments != null && !comments.trim().isEmpty() && !"null".equalsIgnoreCase(comments.trim())) { + String safeComments = escapeSpecialRegexCharacters(comments.trim()); + regexCriteria.add(Criteria.where("problemDetails").regex(safeComments, "i")); + } + + // Combine base criteria with all regex criteria with .andOperator() + Criteria finalCriteria; + if (!regexCriteria.isEmpty()) { + List ands = new ArrayList<>(); + ands.add(criteria); + ands.addAll(regexCriteria); + finalCriteria = new Criteria().andOperator(ands.toArray(new Criteria[0])); + } else { + finalCriteria = criteria; + } + + // Aggregation for error keywords + MatchOperation match = Aggregation.match(finalCriteria); + GroupOperation groupByUrl = Aggregation.group("url") + .first("url").as("url") + .first("problemDate").as("problemDate") + .first("institution").as("institution") + .first("title").as("title") + .first("language").as("language") + .first("section").as("section") + .first("theme").as("theme") + .count().as("urlEntries"); + SortOperation sortByEntriesDesc = Aggregation.sort(Sort.Direction.DESC, "urlEntries"); + + Aggregation agg = Aggregation.newAggregation( + match, + groupByUrl, + sortByEntriesDesc, + Aggregation.skip((long) input.getStart()), + Aggregation.limit(input.getLength()) + ); + + AggregationResults results = mongoTemplate.aggregate(agg, "problem", Problem.class); + List groupedProblems = results.getMappedResults(); + + // Total comments and pages + totalPages = mongoTemplate.aggregate( + Aggregation.newAggregation(match, groupByUrl), "problem", Problem.class + ).getMappedResults().size(); + totalComments =(int) mongoTemplate.count(Query.query(finalCriteria), "problem"); + + + // Create a DataTablesOutput instance + DataTablesOutput output = new DataTablesOutput<>(); + output.setData(groupedProblems); + output.setDraw(input.getDraw()); + output.setRecordsTotal(groupedProblems.size()); + output.setRecordsFiltered(groupedProblems.size()); + + output.setRecordsTotal(totalComments); + output.setRecordsFiltered(totalComments); + + // Adjust institution names based on language (same as normal dashboard) + setInstitution(output, pageLang); + + return output; + + //comments filtering + } else if (comments != null && !comments.trim().isEmpty() && !"null".equalsIgnoreCase(comments.trim())) { + + Criteria criteria = buildFilterCriteria(startDate, endDate, theme, section, language, url, department); + + // Only use the comment as a regex filter + String escapedComment = escapeSpecialRegexCharacters(comments.trim()); + LOGGER.info("Applying comment-only regex: '{}'", escapedComment); + criteria.and("problemDetails").regex(escapedComment, "i"); + + // Aggregation + MatchOperation match = Aggregation.match(criteria); + GroupOperation groupByUrl = Aggregation.group("url") + .first("url").as("url") + .first("problemDate").as("problemDate") + .first("institution").as("institution") + .first("title").as("title") + .first("language").as("language") + .first("section").as("section") + .first("theme").as("theme") + .count().as("urlEntries"); + SortOperation sortByEntriesDesc = Aggregation.sort(Sort.Direction.DESC, "urlEntries"); + + // Get all groups for totals + List allGroupedProblems = mongoTemplate.aggregate( + Aggregation.newAggregation(match, groupByUrl), + "problem", + Problem.class + ).getMappedResults(); + + // Calculate totals + totalPages = allGroupedProblems.size(); + totalComments = allGroupedProblems.stream().mapToInt(Problem::getUrlEntries).sum(); + + // Paginate for current page + Aggregation agg = Aggregation.newAggregation( + match, + groupByUrl, + sortByEntriesDesc, + Aggregation.skip((long) input.getStart()), + Aggregation.limit(input.getLength()) + ); + + AggregationResults results = mongoTemplate.aggregate(agg, "problem", Problem.class); + List groupedProblems = results.getMappedResults(); + + // Set up DataTablesOutput + DataTablesOutput output = new DataTablesOutput<>(); + output.setData(groupedProblems); + output.setDraw(input.getDraw()); + output.setRecordsTotal(totalComments); + output.setRecordsFiltered(totalComments); + + + setInstitution(output, pageLang); + + return output; + } + + LOGGER.debug("Retrieving dashboard data"); + List processedProblems = problemCacheService.getProcessedProblems(); + LOGGER.debug("Retrieved {} problems for dashboard data", processedProblems.size()); + + problems = + new ArrayList<>( + processedProblems.stream() + .collect( + Collectors.groupingBy( + p -> new AbstractMap.SimpleEntry<>(p.getUrl(), p.getProblemDate()), + Collectors.collectingAndThen( + Collectors.toList(), + list -> { + Problem problem = new Problem(); + problem.setUrl(list.get(0).getUrl()); + problem.setProblemDate(list.get(0).getProblemDate()); + problem.setUrlEntries(list.size()); + problem.setInstitution(list.get(0).getInstitution()); + problem.setTitle(list.get(0).getTitle()); + problem.setLanguage(list.get(0).getLanguage()); + problem.setSection(list.get(0).getSection()); + problem.setTheme(list.get(0).getTheme()); + return problem; + }))) + .values()); + + LocalDate currentDate = LocalDate.now(); + problems = + problems.stream() + .filter( + p -> { + LocalDate problemDate = + LocalDate.parse(p.getProblemDate(), DateTimeFormatter.ISO_LOCAL_DATE); + return !problemDate.isAfter(currentDate); //was isBefore - changed to !isAfter for more entries including current date + }) + .collect(Collectors.toList()); + // Apply filters + problems = + applyFilters(problems, department, startDate, endDate, language, url, section, theme); + + // Sort problems by URL entries in descending order + problems.sort(Comparator.comparingInt(Problem::getUrlEntries).reversed()); + + // Merge problems with the same URL + List mergedProblems = mergeProblems(problems); + + mergedProblems.sort(Comparator.comparingInt(Problem::getUrlEntries).reversed()); + // Calculate total comments and pages + totalComments = mergedProblems.stream().mapToInt(Problem::getUrlEntries).sum(); + totalPages = mergedProblems.size(); + + // Apply pagination + List paginatedProblems = + applyPagination(mergedProblems, input.getStart(), input.getLength()); + + // Create a DataTablesOutput instance and set the paginated data + DataTablesOutput output = new DataTablesOutput<>(); + output.setData(paginatedProblems); + output.setDraw(input.getDraw()); + output.setRecordsTotal(mergedProblems.size()); + output.setRecordsFiltered(mergedProblems.size()); + + output.setRecordsTotal(totalComments); + output.setRecordsFiltered(totalComments); + + // Adjust institution names based on language + setInstitution(output, pageLang); + long afterUsedMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + long actualMemUsed = afterUsedMem - beforeUsedMem; + LOGGER.info("Memory used by yourMethod(): {} bytes", actualMemUsed); + + return output; + } + //Helper method for criteria building with filters + private Criteria buildFilterCriteria(String startDate, String endDate, String theme, + String section, String language, String url, + String department) { + Criteria criteria = Criteria.where("processed").is("true"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + if (startDate != null && !startDate.isEmpty() && endDate != null && !endDate.isEmpty()) { + LocalDate start = LocalDate.parse(startDate, formatter); + LocalDate end = LocalDate.parse(endDate, formatter); + criteria.and("problemDate").gte(start.format(formatter)).lte(end.format(formatter)); + } + if (theme != null && !theme.isEmpty()) { + criteria.and("theme").is(theme); + } + if (section != null && !section.isEmpty()) { + criteria.and("section").in(sectionMappings.getOrDefault(section.toLowerCase(), Collections.singletonList(section))); + } + if (language != null && !language.isEmpty()) { + criteria.and("language").is(language); + } + if (url != null && !url.isEmpty()) { + criteria.and("url").regex(url, "i"); + } + if (department != null && !department.isEmpty()) { + Set matchingVariations = new HashSet<>(); + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().stream().anyMatch(variation -> variation.equalsIgnoreCase(department))) { + matchingVariations.addAll(entry.getValue()); + } + } + if (!matchingVariations.isEmpty()) { + criteria.and("institution").in(matchingVariations); + } + } + return criteria; + } + + private String escapeSpecialRegexCharacters(String input) { + // Escape all regex metacharacters + return input.replaceAll("([\\\\.^$|()\\[\\]{}*+?])", "\\\\$1"); + } + + + private Problem createProblemFromResult(Map result) { + Problem problem = new Problem(); + problem.setUrl((String) result.get("url")); + problem.setProblemDate((String) result.get("day")); + problem.setUrlEntries((Integer) result.get("count")); + problem.setTitle((String) result.get("title")); + problem.setLanguage((String) result.get("language")); + problem.setInstitution((String) result.get("institution")); + problem.setTheme((String) result.get("theme")); + problem.setSection((String) result.get("section")); + return problem; + } + + private List applyFilters( + List problems, + String department, + String startDate, + String endDate, + String language, + String url, + String section, + String theme) { + problems = applyDepartmentFilter(problems, department, institutionMappings); + problems = applyDateRangeFilter(problems, startDate, endDate); + problems = applyLanguageFilter(problems, language); + problems = applyUrlFilter(problems, url); + problems = applySectionFilter(problems, section); + problems = applyThemeFilter(problems, theme); + + return problems; + } + + // Extract filter methods here... + + private List mergeProblems(List problems) { + Map urlToProblemMap = new LinkedHashMap<>(); + + for (Problem problem : problems) { + urlToProblemMap.merge( + problem.getUrl(), + problem, + (existingProblem, newProblem) -> { + Problem updatedProblem = new Problem(existingProblem); + updatedProblem.setUrlEntries( + existingProblem.getUrlEntries() + newProblem.getUrlEntries()); + return updatedProblem; + }); + } + + return new ArrayList<>(urlToProblemMap.values()); + } + + private List applyLanguageFilter(List problems, String language) { + if (language != null && !language.isEmpty()) { + return problems.stream() + .filter(problem -> problem.getLanguage().equals(language)) + .collect(Collectors.toList()); + } + return problems; + } + + // DEPT + private List applyDateRangeFilter( + List problems, String startDate, String endDate) { + if (startDate != null && endDate != null) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + LocalDate start = LocalDate.parse(startDate, formatter); + LocalDate end = LocalDate.parse(endDate, formatter); + + return problems.stream() + .filter( + problem -> { + LocalDate problemDate = LocalDate.parse(problem.getProblemDate(), formatter); + return !problemDate.isBefore(start) && !problemDate.isAfter(end); + }) + .collect(Collectors.toList()); + } + return problems; + } + + + + private List applyDepartmentFilter( + List problems, String department, Map> institutionMappings) { + if (department != null && !department.isEmpty()) { + Set matchingVariations = new HashSet<>(); + // Filter variations based on department: + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().stream() + .anyMatch(variation -> variation.equalsIgnoreCase(department))) { + matchingVariations.addAll(entry.getValue()); + } + } + if (!matchingVariations.isEmpty()) { + return problems.stream() + .filter(problem -> matchingVariations.contains(problem.getInstitution())) + .collect(Collectors.toList()); + } + } + return problems; + } + + private List applySectionFilter(List problems, String section) { + if (section != null && !section.isEmpty()) { + return problems.stream() + .filter(problem -> sectionMappings.getOrDefault(section.toLowerCase(), Collections.singletonList(section)).contains(problem.getSection())) + .collect(Collectors.toList()); + } + return problems; + } + + // theme + private List applyThemeFilter(List problems, String theme) { + if (theme != null && !theme.isEmpty()) { + return problems.stream() + .filter(problem -> problem.getTheme().equals(theme)) + .collect(Collectors.toList()); + } + return problems; + } + + private List applyUrlFilter(List problems, String url) { + if (url != null && !url.isEmpty()) { + String filterUrl = url.toLowerCase(); + return problems.stream() + .filter(problem -> problem.getUrl().toLowerCase().contains(filterUrl)) + .collect(Collectors.toList()); + } + return problems; + } + + private List applyPagination(List mergedProblems, int start, int length) { + return mergedProblems.stream().skip(start).limit(length).collect(Collectors.toList()); + } + + private void setInstitution(DataTablesOutput problems, String lang) { + for (Problem problem : problems.getData()) { + String currentInstitution = problem.getInstitution(); + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().contains(currentInstitution)) { + // Assuming the translated institution name is at index 1 for French and index 0 + // for other languages + problem.setInstitution(entry.getValue().get(lang.equalsIgnoreCase("fr") ? 1 : 0)); + break; // Exit the loop once the institution is found and updated + } + } + } + } + + public UserService getUserService() { + return userService; + } + + public void setUserService(UserService userService) { + this.userService = userService; + } +} diff --git a/src/main/java/ca/gc/tbs/controller/ImportController.java b/src/main/java/ca/gc/tbs/controller/ImportController.java new file mode 100644 index 00000000..d7ba030f --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/ImportController.java @@ -0,0 +1,78 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.Problem; +import ca.gc.tbs.repository.ProblemRepository; +import ca.gc.tbs.service.ContentService; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.RedirectView; +// + +@Controller +public class ImportController { + + @Autowired ProblemRepository problemRepository; + + @Autowired ContentService contentService; + + SimpleDateFormat OUTPUT_FORMAT = new SimpleDateFormat("yyyy-MM-dd"); + + @GetMapping(value = "/importcsv") + public View importData() throws Exception { + final Reader reader = + new InputStreamReader( + new URL( + "https://docs.google.com/spreadsheets/d/1tTNrPJqKyNNkJo1UaCoSp1RMpSz3dJsRKmieDglSAOU/export?format=csv") + .openConnection() + .getInputStream(), + "UTF-8"); + final CSVParser parser = new CSVParser(reader, CSVFormat.DEFAULT.withHeader()); + try { + for (final CSVRecord record : parser) { + try { + Problem problem = new Problem(); + problem.setId(record.get("Ref Number").replace("/", "")); + problem.setProblemDate(record.get("Date/time received")); + problem.setTitle(record.get("Page Title")); + problem.setUrl(record.get("Page URL")); + problem.setProblemDetails(record.get("Details")); + String[] topics = record.get("Topic").trim().split(","); + if (topics.length > 0) { + problem.setTags(Arrays.asList(topics)); + } + problem.setInstitution("Health"); + if (problem.getUrl().contains("/en/")) { + problem.setLanguage("en"); + } else { + problem.setLanguage("fr"); + } + problem.setProcessed("false"); + problem.setAirTableSync("false"); + problem.setAutoTagProcessed("false"); + problem.setPersonalInfoProcessed("false"); + problem.setDataOrigin("Health CSV"); + this.problemRepository.save(problem); + + } catch (Exception e) { + System.out.println(e.getMessage()); + e.printStackTrace(); + } + } + + } finally { + parser.close(); + reader.close(); + } + return new RedirectView("/pageFeedback"); + } +} diff --git a/src/main/java/ca/gc/tbs/controller/LoginController.java b/src/main/java/ca/gc/tbs/controller/LoginController.java new file mode 100644 index 00000000..a731c734 --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/LoginController.java @@ -0,0 +1,98 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.User; +import ca.gc.tbs.service.EmailService; +import ca.gc.tbs.service.UserService; +import java.text.SimpleDateFormat; +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.springframework.web.servlet.view.RedirectView; + +@Controller +public class LoginController { + public static final String DATE_FORMAT = "yyyy-MM-dd"; + public static final SimpleDateFormat format = new SimpleDateFormat(DATE_FORMAT); + + @Autowired private UserService userService; + + @Autowired private EmailService emailService; + + @RequestMapping(value = "/login", method = RequestMethod.GET) + public ModelAndView login(HttpServletRequest request) throws Exception { + ModelAndView modelAndView = new ModelAndView(); + String lang = (String) request.getSession().getAttribute("lang"); + // System.out.println(lang); + modelAndView.setViewName("login_" + lang); + return modelAndView; + } + + @RequestMapping(value = "/signup", method = RequestMethod.GET) + public ModelAndView signup(HttpServletRequest request) { + ModelAndView modelAndView = new ModelAndView(); + User user = new User(); + String lang = (String) request.getSession().getAttribute("lang"); + modelAndView.addObject("user", user); + modelAndView.addObject("institutions", this.userService.findInstitutions()); + modelAndView.setViewName("signup_" + lang); + return modelAndView; + } + + @GetMapping("/checkExists") + public @ResponseBody String checkExists(@RequestParam String email) { + // verify that one has not already been created. + User userExists = userService.findUserByEmail(email); + if (userExists != null) { + return "true"; + } else { + return "false"; + } + } + + @RequestMapping(value = "/signup", method = RequestMethod.POST) + public RedirectView createNewUser(@Valid User user, RedirectAttributes atts) { + userService.saveUser(user); + emailService.sendUserActivationRequestEmail(user.getEmail()); + atts.addFlashAttribute( + "successMessage", + "User has been registered successfully. You will be notified when the account has been" + + " activated."); + return new RedirectView("success"); + } + + @RequestMapping( + value = {"/success"}, + method = RequestMethod.GET) + public ModelAndView success(HttpServletRequest request) { + String lang = (String) request.getSession().getAttribute("lang"); + ModelAndView mav = new ModelAndView(); + mav.setViewName("success_" + lang); + return mav; + } + + // redirects localhost to sign in page. + @RequestMapping( + value = {"/"}, + method = RequestMethod.GET) + public View home(@Valid User user) { + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setViewName("home"); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + RedirectView view = new RedirectView("/pageFeedback"); + + // if user is not logged in, redirect to signin + if (auth.getName().equals("anonymousUser")) { + view = new RedirectView("signin"); + } + + return view; + } +} diff --git a/src/main/java/ca/gc/tbs/controller/ProblemController.java b/src/main/java/ca/gc/tbs/controller/ProblemController.java new file mode 100644 index 00000000..56526263 --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/ProblemController.java @@ -0,0 +1,1097 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.Problem; +import ca.gc.tbs.domain.User; +import ca.gc.tbs.repository.ProblemRepository; +import ca.gc.tbs.security.JWTUtil; +import ca.gc.tbs.service.ErrorKeywordService; +import ca.gc.tbs.service.ProblemDateService; +import ca.gc.tbs.service.UserService; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.bson.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.datatables.DataTablesInput; +import org.springframework.data.mongodb.datatables.DataTablesOutput; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.io.Writer; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.regex.Pattern; + +@Controller +public class ProblemController { + + private static final Logger LOG = LoggerFactory.getLogger(ProblemController.class); + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemDateService problemDateService; + + @Autowired + private ErrorKeywordService errorKeywordService; + + @Autowired + private UserService userService; + + private static final Map> institutionMappings = new HashMap<>(); + private static final Map> sectionMappings = new HashMap<>(); + + static { + // Initialize section mappings + sectionMappings.put("disability", Arrays.asList("disability", "disability benefits")); + + // Initialize institution mappings + institutionMappings.put( + "AAFC", + Arrays.asList( + "AAFC", + "AAC", + "AGRICULTURE AND AGRI-FOOD CANADA", + "AGRICULTURE ET AGROALIMENTAIRE CANADA", + "AAFC / AAC")); + institutionMappings.put( + "ACOA", + Arrays.asList( + "ACOA", + "APECA", + "ATLANTIC CANADA OPPORTUNITIES AGENCY", + "AGENCE DE PROMOTION ÉCONOMIQUE DU CANADA ATLANTIQUE", + "ACOA / APECA")); + institutionMappings.put( + "ATSSC", + Arrays.asList( + "ATSSC", + "SCDATA", + "ADMINISTRATIVE TRIBUNALS SUPPORT SERVICE OF CANADA", + "SERVICE CANADIEN D'APPUI AUX TRIBUNAUX ADMINISTRATIFS", + "ATSSC / SCDATA")); + institutionMappings.put( + "CANNOR", + Arrays.asList( + "CANNOR", + "RNCAN", + "CANADIAN NORTHERN ECONOMIC DEVELOPMENT AGENCY", + "AGENCE CANADIENNE DE DÉVELOPPEMENT ÉCONOMIQUE DU NORD", + "CANNOR / RNCAN")); + institutionMappings.put( + "CATSA", + Arrays.asList( + "CATSA", + "ACSTA", + "CANADIAN AIR TRANSPORT SECURITY AUTHORITY", + "ADMINISTRATION CANADIENNE DE LA SÛRETÉ DU TRANSPORT AÉRIEN", + "CATSA / ACSTA")); + institutionMappings.put( + "CBSA", + Arrays.asList( + "CBSA", + "ASFC", + "CANADA BORDER SERVICES AGENCY", + "AGENCE DES SERVICES FRONTALIERS DU CANADA", + "CBSA / ASFC")); + institutionMappings.put( + "CCG", + Arrays.asList( + "CCG", "GCC", "CANADIAN COAST GUARD", "GARDE CÔTIÈRE CANADIENNE", "CCG / GCC")); + institutionMappings.put( + "CER", + Arrays.asList( + "CER", "REC", "CANADA ENERGY REGULATOR", "RÉGIE DE L'ÉNERGIE DU CANADA", "CER / REC")); + institutionMappings.put( + "CFIA", + Arrays.asList( + "CFIA", + "ACIA", + "CANADIAN FOOD INSPECTION AGENCY", + "AGENCE CANADIENNE D'INSPECTION DES ALIMENTS", + "CFIA / ACIA")); + institutionMappings.put( + "CGC", + Arrays.asList( + "CGC", + "CANADIAN GRAIN COMMISSION", + "COMMISSION CANADIENNE DES GRAINS", + "CGC")); + institutionMappings.put( + "CIHR", + Arrays.asList( + "CIHR", + "IRSC", + "CANADIAN INSTITUTES OF HEALTH RESEARCH", + "INSTITUTS DE RECHERCHE EN SANTÉ DU CANADA", + "CIHR / IRSC")); + institutionMappings.put( + "CIPO", + Arrays.asList( + "CIPO", + "OPIC", + "CANADIAN INTELLECTUAL PROPERTY OFFICE", + "OFFICE DE LA PROPRIÉTÉ INTELLECTUELLE DU CANADA", + "CIPO / OPIC")); + institutionMappings.put( + "CIRNAC", + Arrays.asList( + "CIRNAC", + "RCAANC", + "CROWN-INDIGENOUS RELATIONS AND NORTHERN AFFAIRS CANADA", + "RELATIONS COURONNE-AUTOCHTONES ET AFFAIRES DU NORD CANADA", + "CIRNAC / RCAANC")); + institutionMappings.put( + "CRA", + Arrays.asList( + "CRA", "ARC", "CANADA REVENUE AGENCY", "AGENCE DU REVENU DU CANADA", "CRA / ARC")); + institutionMappings.put( + "CRTC", + Arrays.asList( + "CRTC", + "CRTC", + "CANADIAN RADIO-TELEVISION AND TELECOMMUNICATIONS COMMISSION", + "CONSEIL DE LA RADIODIFFUSION ET DES TÉLÉCOMMUNICATIONS CANADIENNES", + "CRTC / CRTC")); + institutionMappings.put( + "CSA", + Arrays.asList( + "CSA", "ASC", "CANADIAN SPACE AGENCY", "AGENCE SPATIALE CANADIENNE", "CSA / ASC")); + institutionMappings.put( + "CSC", + Arrays.asList( + "CSC", + "SCC", + "CORRECTIONAL SERVICE CANADA", + "SERVICE CORRECTIONNEL CANADA", + "CSC / SCC")); + institutionMappings.put( + "CSE", + Arrays.asList( + "CSE", + "CST", + "COMMUNICATIONS SECURITY ESTABLISHMENT", + "CENTRE DE LA SÉCURITÉ DES TÉLÉCOMMUNICATIONS", + "CSE / CST")); + institutionMappings.put( + "CSEC", + Arrays.asList( + "CSEC", + "CSTC", + "COMMUNICATIONS SECURITY ESTABLISHMENT CANADA", + "CENTRE DE LA SÉCURITÉ DES TÉLÉCOMMUNICATIONS CANADA", + "CSEC / CSTC")); + institutionMappings.put( + "CSPS", + Arrays.asList( + "CSPS", + "EFPC", + "CANADA SCHOOL OF PUBLIC SERVICE", + "ÉCOLE DE LA FONCTION PUBLIQUE DU CANADA", + "CSPS / EFPC")); + institutionMappings.put( + "DFO", + Arrays.asList( + "DFO", "MPO", "FISHERIES AND OCEANS CANADA", "PÊCHES ET OCÉANS CANADA", "DFO / MPO", "GOVERNMENT OF CANADA, FISHERIES AND OCEANS CANADA, COMMUNICATIONS BRANCH")); + institutionMappings.put( + "DND", Arrays.asList("DND", "MDN", "NATIONAL DEFENCE", "DÉFENSE NATIONALE", "DND / MDN")); + institutionMappings.put( + "ECCC", + Arrays.asList( + "ECCC", + "ECCC", + "ENVIRONMENT AND CLIMATE CHANGE CANADA", + "ENVIRONNEMENT ET CHANGEMENT CLIMATIQUE CANADA", + "ECCC / ECCC")); + institutionMappings.put( + "ESDC", + Arrays.asList( + "ESDC", + "EDSC", + "EMPLOYMENT AND SOCIAL DEVELOPMENT CANADA", + "EMPLOI ET DÉVELOPPEMENT SOCIAL CANADA", + "ESDC / EDSC")); + institutionMappings.put( + "FCAC", + Arrays.asList( + "FCAC", + "ACFC", + "FINANCIAL CONSUMER AGENCY OF CANADA", + "AGENCE DE LA CONSOMMATION EN MATIÈRE FINANCIÈRE DU CANADA", + "FCAC / ACFC")); + institutionMappings.put( + "FIN", + Arrays.asList( + "FIN", + "FIN", + "FINANCE CANADA", + "MINISTÈRE DES FINANCES CANADA", + "DEPARTMENT OF FINANCE CANADA", + "GOVERNMENT OF CANADA, DEPARTMENT OF FINANCE", + "MINISTÈRE DES FINANCES", + "FIN / FIN")); + institutionMappings.put( + "GAC", + Arrays.asList( + "GAC", "AMC", "GLOBAL AFFAIRS CANADA", "AFFAIRES MONDIALES CANADA", "GAC / AMC")); + institutionMappings.put( + "HC", Arrays.asList("HC", "SC", "HEALTH CANADA", "SANTÉ CANADA", "HC / SC")); + institutionMappings.put( + "INFC", + Arrays.asList( + "INFC", "INFC", "INFRASTRUCTURE CANADA", "INFRASTRUCTURE CANADA", "INFC / INFC")); + institutionMappings.put( + "IOGC", + Arrays.asList( + "IOGC", + "BPGI", + "INDIAN OIL AND GAS CANADA", + "BUREAU DU PÉTROLE ET DU GAZ DES INDIENS", + "IOGC / BPGI")); + institutionMappings.put( + "IRCC", + Arrays.asList( + "IRCC", + "IRCC", + "IMMIGRATION, REFUGEES AND CITIZENSHIP CANADA", + "IMMIGRATION, RÉFUGIÉS ET CITOYENNETÉ CANADA", + "IRCC / IRCC")); + institutionMappings.put( + "ISC", + Arrays.asList( + "ISC", + "SAC", + "INDIGENOUS SERVICES CANADA", + "SERVICES AUX AUTOCHTONES CANADA", + "ISC / SAC")); + institutionMappings.put( + "ISED", + Arrays.asList( + "ISED", + "ISDE", + "INNOVATION, SCIENCE AND ECONOMIC DEVELOPMENT CANADA", + "INNOVATION, SCIENCES ET DÉVELOPPEMENT ÉCONOMIQUE CANADA", + "ISED / ISDE")); + institutionMappings.put( + "JUS", + Arrays.asList( + "JUS", "JUS", "JUSTICE CANADA", "MINISTÈRE DE LA JUSTICE CANADA", "JUS / JUS")); + institutionMappings.put( + "LAC", + Arrays.asList( + "LAC", + "BAC", + "LIBRARY AND ARCHIVES CANADA", + "BIBLIOTHÈQUE ET ARCHIVES CANADA", + "LAC / BAC")); + institutionMappings.put( + "NFB", + Arrays.asList("NFB", "ONF", "NATIONAL FILM BOARD", "OFFICE NATIONAL DU FILM", "NFB / ONF")); + institutionMappings.put( + "NRC", + Arrays.asList( + "NRC", + "CNRC", + "NATIONAL RESEARCH COUNCIL", + "CONSEIL NATIONAL DE RECHERCHES CANADA", + "NRC / CNRC")); + institutionMappings.put( + "NRCAN", + Arrays.asList( + "NRCAN", + "RNCAN", + "NATURAL RESOURCES CANADA", + "RESSOURCES NATURELLES CANADA", + "NRCAN / RNCAN")); + institutionMappings.put( + "NSERC", + Arrays.asList( + "NSERC", + "CRSNG", + "NATURAL SCIENCES AND ENGINEERING RESEARCH CANADA", + "CONSEIL DE RECHERCHES EN SCIENCES NATURELLES ET EN GÉNIE DU CANADA", + "NSERC / CRSNG")); + institutionMappings.put( + "OMBDNDCAF", + Arrays.asList( + "OMBDNDCAF", + "OMBMDNFAC", + "DND / CAF OMBUDSMAN", + "OMBUDSMAN DU MDN / FAC", + "OFFICE OF THE NATIONAL DEFENCE AND CANADIAN ARMED FORCES OMBUDSMAN", + "BUREAU DE L'OMBUDSMAN DE LA DÉFENSE NATIONALE ET DES FORCES ARMÉES CANADIENNES", + "OMBDNDCAF / OMBMDNFAC")); + institutionMappings.put( + "OSB", + Arrays.asList( + "OSB", + "BSF", + "SUPERINTENDENT OF BANKRUPTCY CANADA", + "BUREAU DU SURINTENDANT DES FAILLITES CANADA", + "OSB / BSF")); + institutionMappings.put( + "PBC", + Arrays.asList( + "PBC", + "CLCC", + "PAROLE BOARD OF CANADA", + "COMMISSION DES LIBÉRATIONS CONDITIONNELLES DU CANADA", + "PBC / CLCC")); + institutionMappings.put( + "PC", Arrays.asList("PC", "PC", "PARCS CANADA", "PARKS CANADA", "PC / PC")); + institutionMappings.put( + "PCH", + Arrays.asList("PCH", "PCH", "CANADIAN HERITAGE", "PATRIMOINE CANADIEN", "PCH / PCH")); + institutionMappings.put( + "PCO", + Arrays.asList( + "PCO", "BCP", "PRIVY COUNCIL OFFICE", "BUREAU DU CONSEIL PRIVÉ", "PCO / BCP")); + institutionMappings.put( + "PHAC", + Arrays.asList( + "PHAC", + "ASPC", + "PUBLIC HEALTH AGENCY OF CANADA", + "AGENCE DE LA SANTÉ PUBLIQUE DU CANADA", + "PHAC / ASPC")); + institutionMappings.put( + "PS", + Arrays.asList("PS", "SP", "PUBLIC SAFETY CANADA", "SÉCURITÉ PUBLIQUE CANADA", "PS / SP")); + institutionMappings.put( + "PSC", + Arrays.asList( + "PSC", + "CFP", + "PUBLIC SERVICE COMMISSION OF CANADA", + "COMMISSION DE LA FONCTION PUBLIQUE DU CANADA", + "PSC / CFP")); + institutionMappings.put( + "PSPC", + Arrays.asList( + "PSPC", + "SPAC", + "PUBLIC SERVICES AND PROCUREMENT CANADA", + "SERVICES PUBLICS ET APPROVISIONNEMENT CANADA", + "GOUVERNEMENT DU CANADA, SERVICES PUBLICS ET APPROVISIONNEMENT CANADA", + "GOVERNMENT OF CANADA, PUBLIC SERVICES AND PROCUREMENT CANADA", + "PSPC / SPAC")); + institutionMappings.put( + "RCMP", + Arrays.asList( + "RCMP", + "GRC", + "ROYAL CANADIAN MOUNTED POLICE", + "GENDARMERIE ROYALE DU CANADA", + "RCMP / GRC")); + institutionMappings.put( + "SC", Arrays.asList("SC", "SC", "SERVICE CANADA", "SERVICE CANADA", "SC / SC")); + institutionMappings.put( + "SSC", + Arrays.asList( + "SSC", "PSC", "SHARED SERVICES CANADA", "SERVICES PARTAGÉS CANADA", "SSC / PSC")); + institutionMappings.put( + "SSHRC", + Arrays.asList( + "SSHRC", + "CRSH", + "SOCIAL SCIENCES AND HUMANITIES RESEARCH COUNCIL", + "CONSEIL DE RECHERCHES EN SCIENCES HUMAINES", + "SSHRC / CRSH")); + institutionMappings.put( + "SST", + Arrays.asList( + "SST", + "TSS", + "SOCIAL SECURITY TRIBUNAL OF CANADA", + "TRIBUNAL DE LA SÉCURITÉ SOCIALE DU CANADA", + "SST / TSS")); + institutionMappings.put( + "STATCAN", + Arrays.asList( + "STATCAN", "STATCAN", "STATISTICS CANADA", "STATISTIQUE CANADA", "STATCAN / STATCAN")); + institutionMappings.put( + "TBS", + Arrays.asList( + "TBS", + "SCT", + "TREASURY BOARD OF CANADA SECRETARIAT", + "SECRÉTARIAT DU CONSEIL DU TRÉSOR DU CANADA", + "TBS / SCT")); + institutionMappings.put( + "TC", Arrays.asList("TC", "TC", "TRANSPORT CANADA", "TRANSPORTS CANADA", "TC / TC")); + institutionMappings.put( + "VAC", + Arrays.asList( + "VAC", "ACC", "VETERANS AFFAIRS CANADA", "ANCIENS COMBATTANTS CANADA", "VAC / ACC")); + institutionMappings.put( + "WAGE", + Arrays.asList( + "WAGE", + "FEGC", + "WOMEN AND GENDER EQUALITY CANADA", + "FEMMES ET ÉGALITÉ DES GENRES CANADA", + "WAGE / FEGC")); + institutionMappings.put( + "WD", + Arrays.asList( + "WD", + "DEO", + "WESTERN ECONOMIC DIVERSIFICATION CANADA", + "DIVERSIFICATION DE L'ÉCONOMIE DE L'OUEST CANADA", + "WD / DEO")); + } + + @GetMapping("/pageTitles") + @ResponseBody + public List getPageTitles( + @RequestParam(name = "search", required = false) String search) { + if (search != null && !search.isEmpty()) { + // Use the new repository method to filter page titles based on the search term + return problemRepository.findPageTitlesBySearch(search); + } else { + // Return all page titles if no search term is provided + return problemRepository.findDistinctPageNames(); + } + } + + @Autowired + private MongoTemplate mongoTemplate; + @Autowired + private JWTUtil jwtUtil; + + @GetMapping("/api/problems") + public ResponseEntity getProblemsJson( + @RequestParam Map requestParams, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false) String processedStartDate, + @RequestParam(required = false) String processedEndDate, + @RequestParam(required = false) String institution, + @RequestParam(required = false) String url, + @RequestHeader(name = "Authorization") String authorizationHeader) { + String token = null; + String userName = null; + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + token = authorizationHeader.substring(7); + userName = jwtUtil.extractUsername(token); + } + + if (userName != null) { + User user = userService.findUserByEmail(userName); + if (!userService.isAdmin(user) && !userService.isAPI(user)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Access denied. Only API users & Admins can access this endpoint."); + } + } else { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Authorization header is missing or invalid."); + } + + Set validParams = + new HashSet<>( + Arrays.asList( + "startDate", + "endDate", + "processedStartDate", + "processedEndDate", + "institution", + "url", + "authorizationHeader")); + + for (String param : requestParams.keySet()) { + if (!validParams.contains(param)) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Invalid parameter: " + param); + return ResponseEntity.badRequest().body(errorResponse); + } + } + + Criteria criteria = new Criteria("processed").is("true"); + DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // Ensure only one type of date filter is used + if ((startDate != null || endDate != null) + && (processedStartDate != null || processedEndDate != null)) { + Map errorResponse = new HashMap<>(); + errorResponse.put( + "error", "You can only filter by normal date range or processed date range, not both."); + return ResponseEntity.badRequest().body(errorResponse); + } + + // Validate and apply normal date range filter + if (startDate != null || endDate != null) { + if (startDate == null || endDate == null) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Both startDate and endDate are required."); + return ResponseEntity.badRequest().body(errorResponse); + } else { + try { + LocalDate start = LocalDate.parse(startDate, dateFormat); + LocalDate end = LocalDate.parse(endDate, dateFormat); + if (end.isBefore(start)) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "endDate must be greater than or equal to startDate."); + return ResponseEntity.badRequest().body(errorResponse); + } + criteria.and("problemDate").gte(startDate).lte(endDate); + } catch (DateTimeParseException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Invalid date format. Please use yyyy-MM-dd."); + return ResponseEntity.badRequest().body(errorResponse); + } + } + } + + // Validate and apply processed date range filter + if (processedStartDate != null || processedEndDate != null) { + if (processedStartDate == null || processedEndDate == null) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Both processedStartDate and processedEndDate are required."); + return ResponseEntity.badRequest().body(errorResponse); + } else { + try { + LocalDate processedStart = LocalDate.parse(processedStartDate, dateFormat); + LocalDate processedEnd = LocalDate.parse(processedEndDate, dateFormat); + if (processedEnd.isBefore(processedStart)) { + Map errorResponse = new HashMap<>(); + errorResponse.put( + "error", "processedEndDate must be greater than or equal to processedStartDate."); + return ResponseEntity.badRequest().body(errorResponse); + } + criteria.and("processedDate").gte(processedStartDate).lte(processedEndDate); + } catch (DateTimeParseException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Invalid date format. Please use yyyy-MM-dd."); + return ResponseEntity.badRequest().body(errorResponse); + } + } + } + + // Department filtering + try { + if (institution != null && !institution.isEmpty()) { + criteria = applyDepartmentFilter(criteria, institution); + } + } catch (IllegalArgumentException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + // URL filtering + if (url != null && !url.isEmpty()) { + criteria.and("url").regex(url, "i"); + } + + Query query = new Query(criteria); + query + .fields() + .exclude("_id") + .exclude("section") + .exclude("oppositeLang") + .exclude("processed") + .exclude("contact") + .exclude("urlEntries") + .exclude("resolutionDate") + .exclude("resolution") + .exclude("topic") + .exclude("title") + .exclude("problem") + .exclude("dataOrigin") + .exclude("airTableSync") + .exclude("tags") + .exclude("personalInfoProcessed") + .exclude("autoTagProcessed") + .exclude("_class"); + + List documents = mongoTemplate.find(query, Document.class, "problem"); + return ResponseEntity.ok(documents); + } + + private Criteria applyDepartmentFilter(Criteria criteria, String department) { + Set matchingVariations = new HashSet<>(); + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().stream().anyMatch(variation -> variation.equalsIgnoreCase(department))) { + matchingVariations.addAll(entry.getValue()); + } + } + + if (matchingVariations.isEmpty()) { + throw new IllegalArgumentException("Couldn't find department name: " + department); + } + + criteria.and("institution").in(matchingVariations); + return criteria; + } + + @GetMapping("/exportExcel") + public void exportExcel(HttpServletRequest request, HttpServletResponse response) + throws IOException { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename=\"feedback_export.xlsx\""); + + String[] titles = request.getParameterValues("titles[]"); + String language = request.getParameter("language"); + String department = request.getParameter("department"); + String comments = request.getParameter("comments"); + String theme = request.getParameter("theme"); + String section = request.getParameter("section"); + String url = request.getParameter("url"); + String startDate = request.getParameter("startDate"); + String endDate = request.getParameter("endDate"); + Boolean error_keyword = "true".equals(request.getParameter("error_keyword")); + + Criteria criteria = Criteria.where("processed").is("true"); + + // Apply filters (similar to the existing method) + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + if (startDate != null && endDate != null) { + LocalDate start = LocalDate.parse(startDate, formatter); + LocalDate end = LocalDate.parse(endDate, formatter); + criteria.and("problemDate").gte(start.format(formatter)).lte(end.format(formatter)); + } + if (theme != null && !theme.isEmpty()) { + criteria.and("theme").is(theme); + } + if (section != null && !section.isEmpty()) { + criteria.and("section").in(sectionMappings.getOrDefault(section.toLowerCase(), Collections.singletonList(section))); + } + if (language != null && !language.isEmpty()) { + criteria.and("language").is(language); + } + if (titles != null && titles.length > 0) { + List titleCriterias = new ArrayList<>(); + for (String title : titles) { + titleCriterias.add(Criteria.where("title").is(title)); + } + criteria.orOperator(titleCriterias.toArray(new Criteria[0])); + } + if (url != null && !url.isEmpty()) { + criteria.and("url").regex(url, "i"); + } + if (department != null && !department.isEmpty()) { + Set matchingVariations = new HashSet<>(); + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().stream() + .anyMatch(variation -> variation.equalsIgnoreCase(department))) { + matchingVariations.addAll(entry.getValue()); + } + } + if (!matchingVariations.isEmpty()) { + criteria.and("institution").in(matchingVariations); + } + } + if (comments != null && !comments.isEmpty()) { + String safeComments = escapeSpecialRegexCharacters(comments); + criteria.and("problemDetails").regex(safeComments, "i"); + } + + // Apply error keyword filter + if (error_keyword) { + // Build regex pattern from all keywords + Set keywordsToCheck = new HashSet<>(); + keywordsToCheck.addAll(errorKeywordService.getEnglishKeywords()); + keywordsToCheck.addAll(errorKeywordService.getFrenchKeywords()); + keywordsToCheck.addAll(errorKeywordService.getBilingualKeywords()); + + if (!keywordsToCheck.isEmpty()) { + LOG.debug("Checking {} error keywords for Excel export", keywordsToCheck.size()); + criteria.and("problemDetails").regex(String.join("|", keywordsToCheck), "i"); + } + } + + Query query = new Query(criteria); + query + .fields() + .include("problemDate") + .include("timeStamp") + .include("problemDetails") + .include("language") + .include("title") + .include("url") + .include("institution") + .include("section") + .include("theme") + .include("deviceType") + .include("browser"); + + // Use SXSSFWorkbook for better performance with large data + try (SXSSFWorkbook workbook = + new SXSSFWorkbook(100); // The argument (100) flushes rows after 100 are written + ServletOutputStream outputStream = response.getOutputStream()) { + + Sheet sheet = workbook.createSheet("Feedback Data"); + + // Create header row + String[] columns = { + "Problem Date", + "Time Stamp (UTC)", + "Problem Details", + "Language", + "Title", + "URL", + "Institution", + "Section", + "Theme", + "Device Type", + "Browser" + }; + Row headerRow = sheet.createRow(0); + for (int i = 0; i < columns.length; i++) { + headerRow.createCell(i).setCellValue(columns[i]); + } + + // Stream and write data in batches + final int[] rowNum = {1}; + mongoTemplate.stream(query, Problem.class) + .forEachRemaining( + problem -> { + Row row = sheet.createRow(rowNum[0]++); + row.createCell(0).setCellValue(problem.getProblemDate()); + row.createCell(1).setCellValue(problem.getTimeStamp()); + row.createCell(2).setCellValue(problem.getProblemDetails()); + row.createCell(3).setCellValue(problem.getLanguage()); + row.createCell(4).setCellValue(problem.getTitle()); + row.createCell(5).setCellValue(problem.getUrl()); + row.createCell(6).setCellValue(problem.getInstitution()); + row.createCell(7).setCellValue(problem.getSection()); + row.createCell(8).setCellValue(problem.getTheme()); + row.createCell(9).setCellValue(problem.getDeviceType()); + row.createCell(10).setCellValue(problem.getBrowser()); + + if (rowNum[0] % 100 == 0) { + try { + ((SXSSFSheet) sheet).flushRows(100); // Flush rows every 100 rows + } catch (IOException e) { + LOG.error("Error flushing rows", e); + } + } + }); + + // Write the workbook to the output stream + workbook.write(outputStream); + } catch (Exception e) { + LOG.error("Error exporting Excel", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("Error exporting data: " + e.getMessage()); + } + } + + @GetMapping("/exportCSV") + public void exportCSV(HttpServletRequest request, HttpServletResponse response) + throws IOException { + response.setContentType("text/csv"); + response.setHeader("Content-Disposition", "attachment; filename=\"feedback_export.csv\""); + + String[] titles = request.getParameterValues("titles[]"); + String language = request.getParameter("language"); + String department = request.getParameter("department"); + String comments = request.getParameter("comments"); + String theme = request.getParameter("theme"); + String section = request.getParameter("section"); + String url = request.getParameter("url"); + String startDate = request.getParameter("startDate"); + String endDate = request.getParameter("endDate"); + Boolean error_keyword = "true".equals(request.getParameter("error_keyword")); + + Criteria criteria = Criteria.where("processed").is("true"); + + // Apply filters (similar to the list method) + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + if (startDate != null && endDate != null) { + LocalDate start = LocalDate.parse(startDate, formatter); + LocalDate end = LocalDate.parse(endDate, formatter); + criteria.and("problemDate").gte(start.format(formatter)).lte(end.format(formatter)); + } + + if (theme != null && !theme.isEmpty()) { + criteria.and("theme").is(theme); + } + if (section != null && !section.isEmpty()) { + criteria.and("section").in(sectionMappings.getOrDefault(section.toLowerCase(), Collections.singletonList(section))); + } + if (language != null && !language.isEmpty()) { + criteria + .and("language") + .regex(Pattern.compile(Pattern.quote(language), Pattern.CASE_INSENSITIVE)); + } + if (titles != null && titles.length > 0) { + List titleCriterias = new ArrayList<>(); + for (String title : titles) { + titleCriterias.add(Criteria.where("title").is(title)); + } + criteria.orOperator(titleCriterias.toArray(new Criteria[0])); + } + if (url != null && !url.isEmpty()) { + criteria.and("url").regex(url, "i"); + } + if (department != null && !department.isEmpty()) { + Set matchingVariations = new HashSet<>(); + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().stream() + .anyMatch(variation -> variation.equalsIgnoreCase(department))) { + matchingVariations.addAll(entry.getValue()); + } + } + if (!matchingVariations.isEmpty()) { + criteria.and("institution").in(matchingVariations); + } + } + if (comments != null && !comments.isEmpty()) { + String safeComments = escapeSpecialRegexCharacters(comments); + criteria.and("problemDetails").regex(safeComments, "i"); + } + + // Apply error keyword filter + if (error_keyword) { + // Build regex pattern from all keywords + Set keywordsToCheck = new HashSet<>(); + keywordsToCheck.addAll(errorKeywordService.getEnglishKeywords()); + keywordsToCheck.addAll(errorKeywordService.getFrenchKeywords()); + keywordsToCheck.addAll(errorKeywordService.getBilingualKeywords()); + + if (!keywordsToCheck.isEmpty()) { + LOG.debug("Checking {} error keywords for CSV export", keywordsToCheck.size()); + criteria.and("problemDetails").regex(String.join("|", keywordsToCheck), "i"); + } + } + + Query query = new Query(criteria); + query + .fields() + .include("problemDate") + .include("timeStamp") + .include("problemDetails") + .include("language") + .include("title") + .include("url") + .include("institution") + .include("section") + .include("theme") + .include("deviceType") + .include("browser"); + + // Stream results directly to the response + Writer writer = response.getWriter(); + try { + // Write CSV header + writer.write( + "Problem Date,Time Stamp (UTC),Problem" + + " Details,Language,Title,URL,Institution,Section,Theme,Device Type,Browser\n"); + + // Stream and write data + mongoTemplate.stream(query, Problem.class) + .forEachRemaining( + new java.util.function.Consumer() { + @Override + public void accept(Problem problem) { + try { + writer.write( + String.format( + "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n", + problem.getProblemDate(), + problem.getTimeStamp(), + escapeCSV(problem.getProblemDetails()), + problem.getLanguage(), + escapeCSV(problem.getTitle()), + problem.getUrl(), + problem.getInstitution(), + problem.getSection(), + problem.getTheme(), + problem.getDeviceType(), + problem.getBrowser())); + } catch (IOException e) { + LOG.error("Error writing CSV data", e); + } + } + }); + } finally { + writer.close(); + } + } + + private String escapeCSV(String value) { + if (value == null) { + return ""; + } + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + @GetMapping(value = "/pageFeedback") + public ModelAndView pageFeedback(HttpServletRequest request) throws Exception { + ModelAndView mav = new ModelAndView(); + String lang = (String) request.getSession().getAttribute("lang"); + + // Fetch the aggregation results + Map dateMap = problemDateService.getProblemDates(); + + if (dateMap != null) { + mav.addObject("earliestDate", dateMap.get("earliestDate")); + mav.addObject("latestDate", dateMap.get("latestDate")); + } else { + // Handle the case where no dates are returned + mav.addObject("earliestDate", "N/A"); + mav.addObject("latestDate", "N/A"); + } + mav.addObject("lang", lang); + + mav.setViewName("pageFeedback_" + lang); + return mav; + } + + private boolean containsErrorKeywords(Problem problem) { + if (problem == null || problem.getProblemDetails() == null) { + return false; + } + return errorKeywordService.containsErrorKeywords( + problem.getProblemDetails(), problem.getLanguage()); + } + + @GetMapping(value = "/feedbackData") + @ResponseBody + public DataTablesOutput list(@Valid DataTablesInput input, HttpServletRequest request) { + String pageLang = (String) request.getSession().getAttribute("lang"); + String language = request.getParameter("language"); // Existing language parameter handling + String department = request.getParameter("department"); // Retrieve the department parameter + String comments = request.getParameter("comments"); // Retrieve the comments filter parameter + String theme = request.getParameter("theme"); // Retrieve the theme filter parameter + String section = request.getParameter("section"); // Retrieve the section filter parameter + String url = request.getParameter("url"); // Retrieve the url filter parameter + Boolean error_keyword = "true".equals(request.getParameter("error_keyword")); + String startDate = request.getParameter("startDate"); + String endDate = request.getParameter("endDate"); + String[] titles = request.getParameterValues("titles[]"); + + Criteria criteria = Criteria.where("processed").is("true"); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + if (startDate != null && endDate != null) { + LocalDate start = LocalDate.parse(startDate, formatter); + LocalDate end = LocalDate.parse(endDate, formatter); + criteria.and("problemDate").gte(start.format(formatter)).lte(end.format(formatter)); + } + + if (theme != null && !theme.isEmpty()) { + criteria.and("theme").is(theme); + } + if (section != null && !section.isEmpty()) { + criteria.and("section").in(sectionMappings.getOrDefault(section.toLowerCase(), Collections.singletonList(section))); + } + // Language filtering (existing logic) + if (language != null && !language.isEmpty()) { + criteria.and("language").is(language); + } + + if (titles != null && titles.length > 0) { + // Create a list to hold the title criteria + List titleCriterias = new ArrayList<>(); + // Iterate over the titles and add each one as a criterion + for (String title : titles) { + titleCriterias.add(Criteria.where("title").is(title)); + } + // Combine all title criteria using AND operation + criteria.orOperator(titleCriterias.toArray(new Criteria[0])); + System.out.println("Titles received: " + Arrays.toString(titles)); + } + // URL filtering + if (url != null && !url.isEmpty()) { + criteria.and("url").regex(url, "i"); // 'i' for case-insensitive matching + } + // Department filtering based on institutionMappings + if (department != null && !department.isEmpty()) { + Set matchingVariations = new HashSet<>(); + // Filter variations based on department: + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().stream() + .anyMatch(variation -> variation.equalsIgnoreCase(department))) { + matchingVariations.addAll(entry.getValue()); + } + } + if (!matchingVariations.isEmpty()) { + criteria.and("institution").in(matchingVariations); + } + } + // Comments filtering + if (comments != null && !comments.isEmpty()) { + String safeComments = escapeSpecialRegexCharacters(comments); + criteria.and("problemDetails").regex(safeComments, "i"); // 'i' for case-insensitive matching + } + DataTablesOutput results; + + if (error_keyword) { + // Build regex pattern from all keywords + Set keywordsToCheck = new HashSet<>(); + keywordsToCheck.addAll(errorKeywordService.getEnglishKeywords()); + keywordsToCheck.addAll(errorKeywordService.getFrenchKeywords()); + keywordsToCheck.addAll(errorKeywordService.getBilingualKeywords()); + + if (!keywordsToCheck.isEmpty()) { + LOG.debug("Checking {} error keywords", keywordsToCheck.size()); + criteria.and("problemDetails").regex(String.join("|", keywordsToCheck), "i"); + results = problemRepository.findAll(input, criteria); + } else { + results = problemRepository.findAll(input, criteria); + } + } else { + results = problemRepository.findAll(input, criteria); + } + // Update institution names in the results based on the language + setInstitution(results, pageLang); + // Return the updated results + return results; + } + + /** + * Escapes special regex characters in the input string. + * + * @param input The string to escape. + * @return A string with special regex characters escaped. + */ + private String escapeSpecialRegexCharacters(String input) { + // Escape all regex metacharacters + return input.replaceAll("([\\\\.^$|()\\[\\]{}*+?])", "\\\\$1"); + } + + private void setInstitution(DataTablesOutput problems, String lang) { + for (Problem problem : problems.getData()) { + String currentInstitution = problem.getInstitution(); + for (Map.Entry> entry : institutionMappings.entrySet()) { + if (entry.getValue().contains(currentInstitution)) { + // Assuming the translated institution name is at index 1 for French and index 0 + // for other languages + problem.setInstitution(entry.getValue().get(lang.equalsIgnoreCase("fr") ? 1 : 0)); + break; // Exit the loop once the institution is found and updated + } + } + } + } + + public UserService getUserService() { + return userService; + } + + public void setUserService(UserService userService) { + this.userService = userService; + } +} diff --git a/src/main/java/ca/gc/tbs/controller/ReportController.java b/src/main/java/ca/gc/tbs/controller/ReportController.java new file mode 100644 index 00000000..40915d5a --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/ReportController.java @@ -0,0 +1,106 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.Problem; +import ca.gc.tbs.repository.ProblemRepository; +import java.io.BufferedWriter; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Scanner; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.RedirectView; + +@Controller +public class ReportController { + + @Autowired ProblemRepository problemRepository; + + @Value("${pagesuccess.pythonPath}") + private String pythonPath; + + @Value("${pagesuccess.pythonScriptPath}") + private String pythonScriptPath; + + public static String INPUT_FILENAME = "page_success_may_24.csv"; + + public static String PYTHON_SCRIPT = "page_success_widget.py"; + + public ReportController() {} + + @GetMapping("/reports") + public View generateReports() throws Exception { + new File(Paths.get(pythonPath + "/" + INPUT_FILENAME).toString()).delete(); + BufferedWriter writer = + Files.newBufferedWriter(Paths.get(pythonScriptPath + "/" + INPUT_FILENAME)); + + try (CSVPrinter csvPrinter = + new CSVPrinter( + writer, + CSVFormat.DEFAULT.withHeader( + "Ref Number", + "Date/time received", + "Page Title", + "Page URL", + "Y/N", + "What's wrong", + "Details", + "Topic", + "Personal info", + "(Y/N)", + "Notes", + "", + "Test - auto-topic generator"))) { + + List problems = this.problemRepository.findAll(); + for (Problem problem : problems) { + csvPrinter.printRecord( + problem.getId(), + problem.getProblemDate(), + problem.getTitle(), + problem.getUrl(), + "N", + problem.getProblemDetails(), + String.join(", ", problem.getTags()), + "N", + "", + ""); + } + csvPrinter.flush(); + } + // call python + this.executePython(); + + return new RedirectView("/reports/view"); + } + + @GetMapping("/reports/view") + public String viewReports() { + return "reports"; + } + + public int executePython() throws Exception { + File pathToExecutable = new File(this.pythonPath); + ProcessBuilder builder = new ProcessBuilder(pathToExecutable.getAbsolutePath(), PYTHON_SCRIPT); + builder.directory(new File(this.pythonScriptPath).getAbsoluteFile()); + builder.redirectErrorStream(true); + Process process = builder.start(); + + Scanner s = new Scanner(process.getInputStream()); + StringBuilder text = new StringBuilder(); + while (s.hasNextLine()) { + text.append(s.nextLine()); + text.append("\n"); + } + System.out.println(text); + s.close(); + + return process.waitFor(); + } +} diff --git a/src/main/java/ca/gc/tbs/controller/TopTaskController.java b/src/main/java/ca/gc/tbs/controller/TopTaskController.java new file mode 100644 index 00000000..3db9f0c8 --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/TopTaskController.java @@ -0,0 +1,1124 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.TopTaskSurvey; +import ca.gc.tbs.domain.User; +import ca.gc.tbs.repository.TopTaskRepository; +import ca.gc.tbs.security.JWTUtil; +import ca.gc.tbs.service.ProblemDateService; +import ca.gc.tbs.service.UserService; +import java.io.IOException; +import java.io.Writer; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.bson.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.datatables.DataTablesInput; +import org.springframework.data.mongodb.datatables.DataTablesOutput; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.ModelAndView; + +@Controller +public class TopTaskController { + + private static final Logger LOG = LoggerFactory.getLogger(TopTaskController.class); + @Autowired private TopTaskRepository topTaskRepository; + private int totalDistinctTasks = 0; + + private int totalTaskCount = 0; + @Autowired private UserService userService; + + @Autowired private ProblemDateService problemDateService; + private static final Map> institutionMappings = new HashMap<>(); + + static { + institutionMappings.put( + "AAFC", + Arrays.asList( + "AAFC", + "AAC", + "AGRICULTURE AND AGRI-FOOD CANADA", + "AGRICULTURE ET AGROALIMENTAIRE CANADA", + "AAFC / AAC")); + institutionMappings.put( + "ACOA", + Arrays.asList( + "ACOA", + "APECA", + "ATLANTIC CANADA OPPORTUNITIES AGENCY", + "AGENCE DE PROMOTION ÉCONOMIQUE DU CANADA ATLANTIQUE", + "ACOA / APECA")); + institutionMappings.put( + "ATSSC", + Arrays.asList( + "ATSSC", + "SCDATA", + "ADMINISTRATIVE TRIBUNALS SUPPORT SERVICE OF CANADA", + "SERVICE CANADIEN D’APPUI AUX TRIBUNAUX ADMINISTRATIFS", + "ATSSC / SCDATA")); + institutionMappings.put( + "CANNOR", + Arrays.asList( + "CANNOR", + "RNCAN", + "CANADIAN NORTHERN ECONOMIC DEVELOPMENT AGENCY", + "AGENCE CANADIENNE DE DÉVELOPPEMENT ÉCONOMIQUE DU NORD", + "CANNOR / RNCAN")); + institutionMappings.put( + "CATSA", + Arrays.asList( + "CATSA", + "ACSTA", + "CANADIAN AIR TRANSPORT SECURITY AUTHORITY", + "ADMINISTRATION CANADIENNE DE LA SÛRETÉ DU TRANSPORT AÉRIEN", + "CATSA / ACSTA")); + institutionMappings.put( + "CBSA", + Arrays.asList( + "CBSA", + "ASFC", + "CANADA BORDER SERVICES AGENCY", + "AGENCE DES SERVICES FRONTALIERS DU CANADA", + "CBSA / ASFC")); + institutionMappings.put( + "CCG", + Arrays.asList( + "CCG", "GCC", "CANADIAN COAST GUARD", "GARDE CÔTIÈRE CANADIENNE", "CCG / GCC")); + institutionMappings.put( + "CER", + Arrays.asList( + "CER", "REC", "CANADA ENERGY REGULATOR", "RÉGIE DE L'ÉNERGIE DU CANADA", "CER / REC")); + institutionMappings.put( + "CFIA", + Arrays.asList( + "CFIA", + "ACIA", + "CANADIAN FOOD INSPECTION AGENCY", + "AGENCE CANADIENNE D’INSPECTION DES ALIMENTS", + "CFIA / ACIA")); + institutionMappings.put( + "CGC", + Arrays.asList( + "CGC", + "CCG", + "CANADIAN GRAIN COMMISSION", + "COMMISSION CANADIENNE DES GRAINS", + "CGC / CCG")); + institutionMappings.put( + "CIHR", + Arrays.asList( + "CIHR", + "IRSC", + "CANADIAN INSTITUTES OF HEALTH RESEARCH", + "INSTITUTS DE RECHERCHE EN SANTÉ DU CANADA", + "CIHR / IRSC")); + institutionMappings.put( + "CIPO", + Arrays.asList( + "CIPO", + "OPIC", + "CANADIAN INTELLECTUAL PROPERTY OFFICE", + "OFFICE DE LA PROPRIÉTÉ INTELLECTUELLE DU CANADA", + "CIPO / OPIC")); + institutionMappings.put( + "CIRNAC", + Arrays.asList( + "CIRNAC", + "RCAANC", + "CROWN-INDIGENOUS RELATIONS AND NORTHERN AFFAIRS CANADA", + "RELATIONS COURONNE-AUTOCHTONES ET AFFAIRES DU NORD CANADA", + "CIRNAC / RCAANC")); + institutionMappings.put( + "CRA", + Arrays.asList( + "CRA", "ARC", "CANADA REVENUE AGENCY", "AGENCE DU REVENU DU CANADA", "CRA / ARC")); + institutionMappings.put( + "CRTC", + Arrays.asList( + "CRTC", + "CRTC", + "CANADIAN RADIO-TELEVISION AND TELECOMMUNICATIONS COMMISSION", + "CONSEIL DE LA RADIODIFFUSION ET DES TÉLÉCOMMUNICATIONS CANADIENNES", + "CRTC / CRTC")); + institutionMappings.put( + "CSA", + Arrays.asList( + "CSA", "ASC", "CANADIAN SPACE AGENCY", "AGENCE SPATIALE CANADIENNE", "CSA / ASC")); + institutionMappings.put( + "CSC", + Arrays.asList( + "CSC", + "SCC", + "CORRECTIONAL SERVICE CANADA", + "SERVICE CORRECTIONNEL CANADA", + "CSC / SCC")); + institutionMappings.put( + "CSE", + Arrays.asList( + "CSE", + "CST", + "COMMUNICATIONS SECURITY ESTABLISHMENT", + "CENTRE DE LA SÉCURITÉ DES TÉLÉCOMMUNICATIONS", + "CSE / CST")); + institutionMappings.put( + "CSEC", + Arrays.asList( + "CSEC", + "CSTC", + "COMMUNICATIONS SECURITY ESTABLISHMENT CANADA", + "CENTRE DE LA SÉCURITÉ DES TÉLÉCOMMUNICATIONS CANADA", + "CSEC / CSTC")); + institutionMappings.put( + "CSPS", + Arrays.asList( + "CSPS", + "EFPC", + "CANADA SCHOOL OF PUBLIC SERVICE", + "ÉCOLE DE LA FONCTION PUBLIQUE DU CANADA", + "CSPS / EFPC")); + institutionMappings.put( + "DFO", + Arrays.asList( + "DFO", "MPO", "FISHERIES AND OCEANS CANADA", "PÊCHES ET OCÉANS CANADA", "DFO / MPO")); + institutionMappings.put( + "DND", Arrays.asList("DND", "MDN", "NATIONAL DEFENCE", "DÉFENSE NATIONALE", "DND / MDN")); + institutionMappings.put( + "ECCC", + Arrays.asList( + "ECCC", + "ECCC", + "ENVIRONMENT AND CLIMATE CHANGE CANADA", + "ENVIRONNEMENT ET CHANGEMENT CLIMATIQUE CANADA", + "ECCC / ECCC")); + institutionMappings.put( + "ESDC", + Arrays.asList( + "ESDC", + "EDSC", + "EMPLOYMENT AND SOCIAL DEVELOPMENT CANADA", + "EMPLOI ET DÉVELOPPEMENT SOCIAL CANADA", + "ESDC / EDSC")); + institutionMappings.put( + "FCAC", + Arrays.asList( + "FCAC", + "ACFC", + "FINANCIAL CONSUMER AGENCY OF CANADA", + "AGENCE DE LA CONSOMMATION EN MATIÈRE FINANCIÈRE DU CANADA", + "FCAC / ACFC")); + institutionMappings.put( + "FIN", + Arrays.asList( + "FIN", + "FIN", + "FINANCE CANADA", + "MINISTÈRE DES FINANCES CANADA", + "DEPARTMENT OF FINANCE CANADA", + "GOVERNMENT OF CANADA, DEPARTMENT OF FINANCE", + "MINISTÈRE DES FINANCES", + "FIN / FIN")); + institutionMappings.put( + "GAC", + Arrays.asList( + "GAC", "AMC", "GLOBAL AFFAIRS CANADA", "AFFAIRES MONDIALES CANADA", "GAC / AMC")); + institutionMappings.put( + "HC", Arrays.asList("HC", "SC", "HEALTH CANADA", "SANTÉ CANADA", "HC / SC")); + institutionMappings.put( + "HICC", + Arrays.asList( + "HICC", + "LICC", + "HOUSING, INFRASTRUCTURE AND COMMUNITIES CANADA", + "LOGEMENT, INFRASTRUCTURES ET COLLECTIVITÉS CANADA", + "HICC / LICC")); + institutionMappings.put( + "IOGC", + Arrays.asList( + "IOGC", + "BPGI", + "INDIAN OIL AND GAS CANADA", + "BUREAU DU PÉTROLE ET DU GAZ DES INDIENS", + "IOGC / BPGI")); + institutionMappings.put( + "IRCC", + Arrays.asList( + "IRCC", + "IRCC", + "IMMIGRATION, REFUGEES AND CITIZENSHIP CANADA", + "IMMIGRATION, RÉFUGIÉS ET CITOYENNETÉ CANADA", + "IRCC / IRCC")); + institutionMappings.put( + "ISC", + Arrays.asList( + "ISC", + "SAC", + "INDIGENOUS SERVICES CANADA", + "SERVICES AUX AUTOCHTONES CANADA", + "ISC / SAC")); + institutionMappings.put( + "ISED", + Arrays.asList( + "ISED", + "ISDE", + "INNOVATION, SCIENCE AND ECONOMIC DEVELOPMENT CANADA", + "INNOVATION, SCIENCES ET DÉVELOPPEMENT ÉCONOMIQUE CANADA", + "ISED / ISDE")); + institutionMappings.put( + "JUS", + Arrays.asList( + "JUS", "JUS", "JUSTICE CANADA", "MINISTÈRE DE LA JUSTICE CANADA", "JUS / JUS")); + institutionMappings.put( + "LAC", + Arrays.asList( + "LAC", + "BAC", + "LIBRARY AND ARCHIVES CANADA", + "BIBLIOTHÈQUE ET ARCHIVES CANADA", + "LAC / BAC")); + institutionMappings.put( + "NFB", + Arrays.asList("NFB", "ONF", "NATIONAL FILM BOARD", "OFFICE NATIONAL DU FILM", "NFB / ONF")); + institutionMappings.put( + "NRC", + Arrays.asList( + "NRC", + "CNRC", + "NATIONAL RESEARCH COUNCIL", + "CONSEIL NATIONAL DE RECHERCHES CANADA", + "NRC / CNRC")); + institutionMappings.put( + "NRCAN", + Arrays.asList( + "NRCAN", + "RNCAN", + "NATURAL RESOURCES CANADA", + "RESSOURCES NATURELLES CANADA", + "NRCAN / RNCAN")); + institutionMappings.put( + "NSERC", + Arrays.asList( + "NSERC", + "CRSNG", + "NATURAL SCIENCES AND ENGINEERING RESEARCH CANADA", + "CONSEIL DE RECHERCHES EN SCIENCES NATURELLES ET EN GÉNIE DU CANADA", + "NSERC / CRSNG")); + institutionMappings.put( + "OMBDNDCAF", + Arrays.asList( + "OMBDNDCAF", + "OMBMDNFAC", + "DND / CAF OMBUDSMAN", + "OMBUDSMAN DU MDN / FAC", + "OFFICE OF THE NATIONAL DEFENCE AND CANADIAN ARMED FORCES OMBUDSMAN", + "BUREAU DE L'OMBUDSMAN DE LA DÉFENSE NATIONALE ET DES FORCES ARMÉES CANADIENNES", + "OMBDNDCAF / OMBMDNFAC")); + institutionMappings.put( + "OSB", + Arrays.asList( + "OSB", + "BSF", + "SUPERINTENDENT OF BANKRUPTCY CANADA", + "BUREAU DU SURINTENDANT DES FAILLITES CANADA", + "OSB / BSF")); + institutionMappings.put( + "PBC", + Arrays.asList( + "PBC", + "CLCC", + "PAROLE BOARD OF CANADA", + "COMMISSION DES LIBÉRATIONS CONDITIONNELLES DU CANADA", + "PBC / CLCC")); + institutionMappings.put( + "PC", Arrays.asList("PC", "PC", "PARCS CANADA", "PARKS CANADA", "PC / PC")); + institutionMappings.put( + "PCH", + Arrays.asList("PCH", "PCH", "CANADIAN HERITAGE", "PATRIMOINE CANADIEN", "PCH / PCH")); + institutionMappings.put( + "PCO", + Arrays.asList( + "PCO", "BCP", "PRIVY COUNCIL OFFICE", "BUREAU DU CONSEIL PRIVÉ", "PCO / BCP")); + institutionMappings.put( + "PHAC", + Arrays.asList( + "PHAC", + "ASPC", + "PUBLIC HEALTH AGENCY OF CANADA", + "AGENCE DE LA SANTÉ PUBLIQUE DU CANADA", + "PHAC / ASPC")); + institutionMappings.put( + "PS", + Arrays.asList("PS", "SP", "PUBLIC SAFETY CANADA", "SÉCURITÉ PUBLIQUE CANADA", "PS / SP")); + institutionMappings.put( + "PSC", + Arrays.asList( + "PSC", + "CFP", + "PUBLIC SERVICE COMMISSION OF CANADA", + "COMMISSION DE LA FONCTION PUBLIQUE DU CANADA", + "PSC / CFP")); + institutionMappings.put( + "PSPC", + Arrays.asList( + "PSPC", + "SPAC", + "PUBLIC SERVICES AND PROCUREMENT CANADA", + "SERVICES PUBLICS ET APPROVISIONNEMENT CANADA", + "GOUVERNEMENT DU CANADA, SERVICES PUBLICS ET APPROVISIONNEMENT CANADA", + "GOVERNMENT OF CANADA, PUBLIC SERVICES AND PROCUREMENT CANADA", + "PSPC / SPAC")); + institutionMappings.put( + "RCMP", + Arrays.asList( + "RCMP", + "GRC", + "ROYAL CANADIAN MOUNTED POLICE", + "GENDARMERIE ROYALE DU CANADA", + "RCMP / GRC")); + institutionMappings.put( + "SC", Arrays.asList("SC", "SC", "SERVICE CANADA", "SERVICE CANADA", "SC / SC")); + institutionMappings.put( + "SSC", + Arrays.asList( + "SSC", "PSC", "SHARED SERVICES CANADA", "SERVICES PARTAGÉS CANADA", "SSC / PSC")); + institutionMappings.put( + "SSHRC", + Arrays.asList( + "SSHRC", + "CRSH", + "SOCIAL SCIENCES AND HUMANITIES RESEARCH COUNCIL", + "CONSEIL DE RECHERCHES EN SCIENCES HUMAINES", + "SSHRC / CRSH")); + institutionMappings.put( + "SST", + Arrays.asList( + "SST", + "TSS", + "SOCIAL SECURITY TRIBUNAL OF CANADA", + "TRIBUNAL DE LA SÉCURITÉ SOCIALE DU CANADA", + "SST / TSS")); + institutionMappings.put( + "STATCAN", + Arrays.asList( + "STATCAN", + "STATISTICS CANADA", + "STATISTIQUE CANADA", + "StatCan / StatCan", + "STATCAN / STATCAN")); + institutionMappings.put( + "TBS", + Arrays.asList( + "TBS", + "SCT", + "TREASURY BOARD OF CANADA SECRETARIAT", + "SECRÉTARIAT DU CONSEIL DU TRÉSOR DU CANADA", + "TBS / SCT")); + institutionMappings.put( + "TC", Arrays.asList("TC", "TC", "TRANSPORT CANADA", "TRANSPORTS CANADA", "TC / TC")); + institutionMappings.put( + "VAC", + Arrays.asList( + "VAC", "ACC", "VETERANS AFFAIRS CANADA", "ANCIENS COMBATTANTS CANADA", "VAC / ACC")); + institutionMappings.put( + "WAGE", + Arrays.asList( + "WAGE", + "FEGC", + "WOMEN AND GENDER EQUALITY CANADA", + "FEMMES ET ÉGALITÉ DES GENRES CANADA", + "WAGE / FEGC")); + institutionMappings.put( + "WD", + Arrays.asList( + "WD", + "DEO", + "WESTERN ECONOMIC DIVERSIFICATION CANADA", + "DIVERSIFICATION DE L’ÉCONOMIE DE L’OUEST CANADA", + "WD / DEO")); + } + + @RequestMapping(value = "/topTask/totalDistinctTasks") + @ResponseBody + public String totalDistinctTasks() { + return String.valueOf(totalDistinctTasks); + } + + @RequestMapping(value = "/topTask/totalTaskCount") + @ResponseBody + public String totalTaskCount() { + return String.valueOf(totalTaskCount); + } + + @RequestMapping(value = "/topTaskData", method = {RequestMethod.GET, RequestMethod.POST}) + @ResponseBody + public DataTablesOutput list( + @Valid DataTablesInput input, HttpServletRequest request) { + + // Log request details for debugging + LOG.info("=== TopTaskData Request Debug ==="); + LOG.info("Request URL: {}", request.getRequestURL()); + LOG.info("Query String: {}", request.getQueryString()); + LOG.info("Query String Length: {}", request.getQueryString() != null ? request.getQueryString().length() : 0); + + // Log all parameters + request.getParameterMap().forEach((key, values) -> { + LOG.info("Parameter '{}': {}", key, Arrays.toString(values)); + }); + + String pageLang = (String) request.getSession().getAttribute("lang"); + String departmentFilterVal = request.getParameter("department"); + String themeFilterVal = request.getParameter("theme"); + if (themeFilterVal != null) { + themeFilterVal = themeFilterVal.trim().replaceAll("\\s+", " "); // Trim and normalize spaces + } + String[] taskFilterVals = request.getParameterValues("tasks[]"); + String startDateVal = request.getParameter("startDate"); + String endDateVal = request.getParameter("endDate"); + String groupFilterVal = request.getParameter("group"); + String language = request.getParameter("language"); + String includeCommentsOnlyParam = request.getParameter("includeCommentsOnly"); + boolean includeCommentsOnly = includeCommentsOnlyParam != null && includeCommentsOnlyParam.equals("true"); + String taskCompletionFilterVal = request.getParameter("taskCompletion"); + + + // Log specific filter values + LOG.info("Department: {}", departmentFilterVal); + LOG.info("Theme (cleaned): {}", themeFilterVal); + LOG.info("Tasks count: {}", taskFilterVals != null ? taskFilterVals.length : 0); + if (taskFilterVals != null) { + LOG.info("Tasks: {}", Arrays.toString(taskFilterVals)); + } + LOG.info("Date range: {} to {}", startDateVal, endDateVal); + LOG.info("Task Completion: {}", taskCompletionFilterVal); + + Criteria criteria = Criteria.where("processed").is("true"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + if (startDateVal != null && endDateVal != null) { + LocalDate start = LocalDate.parse(startDateVal, formatter); + LocalDate end = LocalDate.parse(endDateVal, formatter); + criteria.and("dateTime").gte(start.format(formatter)).lte(end.format(formatter)); + } + if (language != null && !language.isEmpty()) { + criteria.and("language").is(language); + } + if (themeFilterVal != null && !themeFilterVal.isEmpty()) { + criteria.and("theme").regex(Pattern.quote(themeFilterVal), "i"); + } + if (groupFilterVal != null && !groupFilterVal.isEmpty()) { + criteria.and("grouping").is(groupFilterVal); + } + if (departmentFilterVal != null && !departmentFilterVal.isEmpty()) { + Criteria departmentCriteria = applyDepartmentFilter(new Criteria(), departmentFilterVal); + criteria = new Criteria().andOperator(criteria, departmentCriteria); + } + + List combinedOrCriteria = new ArrayList<>(); + if (taskFilterVals != null && taskFilterVals.length > 0) { + for (String task : taskFilterVals) { + Criteria taskCriteria = Criteria.where("task").is(task); + combinedOrCriteria.add(taskCriteria); + } + } + + if (includeCommentsOnly) { + List nonEmptyCriteria = createNonEmptyCriteria(); + if (!combinedOrCriteria.isEmpty()) { + List commentCriteriaWithTasks = new ArrayList<>(); + for (Criteria taskCriteria : combinedOrCriteria) { + commentCriteriaWithTasks.add( + new Criteria() + .andOperator( + taskCriteria, + new Criteria().orOperator(nonEmptyCriteria.toArray(new Criteria[0])))); + } + criteria.orOperator(commentCriteriaWithTasks.toArray(new Criteria[0])); + } else { + criteria.orOperator(nonEmptyCriteria.toArray(new Criteria[0])); + } + } else if (!combinedOrCriteria.isEmpty()) { + criteria.orOperator(combinedOrCriteria.toArray(new Criteria[0])); + } + //taskCompletion filter + if (taskCompletionFilterVal != null && !taskCompletionFilterVal.isEmpty()) { + List allowed = new ArrayList<>(); + if (taskCompletionFilterVal.equals("Yes")) { + allowed.add("Yes / Oui"); + } else if (taskCompletionFilterVal.equals("No")) { + allowed.add("No / Non"); + } else if (taskCompletionFilterVal.equals("I started this survey before I finished my visit")) { + allowed.add("I started this survey before I finished my visit / J’ai commencé ce sondage avant d’avoir terminé ma visite"); + } + if (!allowed.isEmpty()) { + criteria.and("taskCompletion").in(allowed); + } + } + + + List distinctTaskCounts = topTaskRepository.findDistinctTaskCountsWithFilters(criteria); + totalDistinctTasks = distinctTaskCounts.size(); + DataTablesOutput results = topTaskRepository.findAll(input, criteria); + + totalTaskCount = (int) results.getRecordsFiltered(); + return results; + } + + @GetMapping("/exportTopTaskExcel") + public void exportTopTaskExcel(HttpServletRequest request, HttpServletResponse response) + throws IOException { + LOG.info("Starting Excel export..."); + + try { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + String currentDate = LocalDate.now().format(dateFormatter); + + // Build criteria from request parameters + Criteria criteria = buildExportCriteria(request); + Query query = buildExportQuery(criteria); + + // Check if we have any data before proceeding + long count = mongoTemplate.count(query, TopTaskSurvey.class); + LOG.info("Found {} records to export", count); + + if (count == 0) { + LOG.warn("No data found for export with the given criteria"); + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write("No data found for export"); + return; + } + + // Set response headers after confirming we have data + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + String filename = "top_task_survey_export_" + currentDate + ".xlsx"; + response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + response.setHeader("Expires", "0"); + + SXSSFWorkbook workbook = new SXSSFWorkbook(100); + Sheet sheet = workbook.createSheet("Top Task Survey Data"); + + // Create header row + String[] columns = { + "Date Time", + "Time Stamp (UTC)", + "Survey Referrer", + "Language", + "Device", + "Screener", + "Department", + "Theme", + "Theme Other", + "Grouping", + "Task", + "Task Other", + "Task Satisfaction", + "Task Ease", + "Task Completion", + "Task Improve", + "Task Improve Comment", + "Task Why Not", + "Task Why Not Comment", + "Task Sampling", + "Sampling Invitation", + "Sampling GC", + "Sampling Canada", + "Sampling Theme", + "Sampling Institution", + "Sampling Grouping", + "Sampling Task" + }; + Row headerRow = sheet.createRow(0); + for (int i = 0; i < columns.length; i++) { + headerRow.createCell(i).setCellValue(columns[i]); + } + + final int[] rowNum = {1}; + try (ServletOutputStream outputStream = response.getOutputStream()) { + mongoTemplate.stream(query, TopTaskSurvey.class) + .forEachRemaining( + survey -> { + try { + Row row = sheet.createRow(rowNum[0]++); + row.createCell(0).setCellValue(survey.getDateTime()); + row.createCell(1).setCellValue(survey.getTimeStamp()); + row.createCell(2).setCellValue(survey.getSurveyReferrer()); + row.createCell(3).setCellValue(survey.getLanguage()); + row.createCell(4).setCellValue(survey.getDevice()); + row.createCell(5).setCellValue(survey.getScreener()); + row.createCell(6).setCellValue(survey.getDept()); + row.createCell(7).setCellValue(survey.getTheme()); + row.createCell(8).setCellValue(survey.getThemeOther()); + row.createCell(9).setCellValue(survey.getGrouping()); + row.createCell(10).setCellValue(survey.getTask()); + row.createCell(11).setCellValue(survey.getTaskOther()); + row.createCell(12).setCellValue(survey.getTaskSatisfaction()); + row.createCell(13).setCellValue(survey.getTaskEase()); + row.createCell(14).setCellValue(survey.getTaskCompletion()); + row.createCell(15).setCellValue(survey.getTaskImprove()); + row.createCell(16).setCellValue(survey.getTaskImproveComment()); + row.createCell(17).setCellValue(survey.getTaskWhyNot()); + row.createCell(18).setCellValue(survey.getTaskWhyNotComment()); + row.createCell(19).setCellValue(survey.getTaskSampling()); + row.createCell(20).setCellValue(survey.getSamplingInvitation()); + row.createCell(21).setCellValue(survey.getSamplingGC()); + row.createCell(22).setCellValue(survey.getSamplingCanada()); + row.createCell(23).setCellValue(survey.getSamplingTheme()); + row.createCell(24).setCellValue(survey.getSamplingInstitution()); + row.createCell(25).setCellValue(survey.getSamplingGrouping()); + row.createCell(26).setCellValue(survey.getSamplingTask()); + + if (rowNum[0] % 100 == 0) { + ((SXSSFSheet) sheet).flushRows(100); + LOG.debug("Flushed {} rows", rowNum[0]); + } + } catch (Exception e) { + LOG.error("Error writing row {}: {}", rowNum[0], e.getMessage()); + } + }); + + LOG.info("Writing {} rows to Excel file", rowNum[0] - 1); + workbook.write(outputStream); + outputStream.flush(); + LOG.info("Excel export completed successfully"); + } catch (Exception e) { + LOG.error("Error writing to output stream", e); + throw e; + } finally { + workbook.dispose(); + workbook.close(); + } + } catch (Exception e) { + LOG.error("Error exporting Excel", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("Error exporting data: " + e.getMessage()); + } + } + + @GetMapping("/exportTopTaskCSV") + public void exportTopTaskCSV(HttpServletRequest request, HttpServletResponse response) + throws IOException { + LOG.info("Starting CSV export..."); + + try { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + String currentDate = LocalDate.now().format(dateFormatter); + + // Build criteria from request parameters + Criteria criteria = buildExportCriteria(request); + Query query = buildExportQuery(criteria); + + // Check if we have any data before proceeding + long count = mongoTemplate.count(query, TopTaskSurvey.class); + LOG.info("Found {} records to export", count); + + if (count == 0) { + LOG.warn("No data found for export with the given criteria"); + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write("No data found for export"); + return; + } + + // Set response headers after confirming we have data + response.setContentType("text/csv"); + String filename = "top_task_survey_export_" + currentDate + ".csv"; + response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + response.setHeader("Expires", "0"); + + try (Writer writer = response.getWriter()) { + // Write CSV header + writer.write( + "Date Time,Time Stamp (UTC),Survey" + + " Referrer,Language,Device,Screener,Department,Theme,Theme" + + " Other,Grouping,Task,Task Other,Task Satisfaction,Task Ease,Task Completion,Task" + + " Improve,Task Improve Comment,Task Why Not,Task Why Not Comment,Task" + + " Sampling,Sampling Invitation,Sampling GC,Sampling Canada,Sampling" + + " Theme,Sampling Institution,Sampling Grouping,Sampling Task\n"); + + // Stream and write data + mongoTemplate.stream(query, TopTaskSurvey.class) + .forEachRemaining( + survey -> { + try { + writer.write( + String.format( + "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n", + escapeCSV(survey.getDateTime()), + escapeCSV(survey.getTimeStamp()), + escapeCSV(survey.getSurveyReferrer()), + escapeCSV(survey.getLanguage()), + escapeCSV(survey.getDevice()), + escapeCSV(survey.getScreener()), + escapeCSV(survey.getDept()), + escapeCSV(survey.getTheme()), + escapeCSV(survey.getThemeOther()), + escapeCSV(survey.getGrouping()), + escapeCSV(survey.getTask()), + escapeCSV(survey.getTaskOther()), + escapeCSV(survey.getTaskSatisfaction()), + escapeCSV(survey.getTaskEase()), + escapeCSV(survey.getTaskCompletion()), + escapeCSV(survey.getTaskImprove()), + escapeCSV(survey.getTaskImproveComment()), + escapeCSV(survey.getTaskWhyNot()), + escapeCSV(survey.getTaskWhyNotComment()), + escapeCSV(survey.getTaskSampling()), + escapeCSV(survey.getSamplingInvitation()), + escapeCSV(survey.getSamplingGC()), + escapeCSV(survey.getSamplingCanada()), + escapeCSV(survey.getSamplingTheme()), + escapeCSV(survey.getSamplingInstitution()), + escapeCSV(survey.getSamplingGrouping()), + escapeCSV(survey.getSamplingTask()))); + } catch (IOException e) { + LOG.error("Error writing CSV row: {}", e.getMessage()); + } + }); + + writer.flush(); + LOG.info("CSV export completed successfully"); + } catch (Exception e) { + LOG.error("Error writing to CSV output", e); + throw e; + } + } catch (Exception e) { + LOG.error("Error exporting CSV", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("Error exporting data: " + e.getMessage()); + } + } + + // Helper methods for the export endpoints + private Criteria buildExportCriteria(HttpServletRequest request) { + String department = request.getParameter("department"); + String theme = request.getParameter("theme"); + String[] tasks = request.getParameterValues("tasks[]"); + String group = request.getParameter("group"); + String language = request.getParameter("language"); + String startDate = request.getParameter("startDate"); + String endDate = request.getParameter("endDate"); + boolean includeCommentsOnly = Boolean.parseBoolean(request.getParameter("includeCommentsOnly")); + + Criteria criteria = Criteria.where("processed").is("true"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + if (startDate != null && endDate != null) { + LocalDate start = LocalDate.parse(startDate, formatter); + LocalDate end = LocalDate.parse(endDate, formatter); + criteria.and("dateTime").gte(start.format(formatter)).lte(end.format(formatter)); + } + if (language != null && !language.isEmpty()) { + criteria.and("language").is(language); + } + if (theme != null && !theme.isEmpty()) { + criteria.and("theme").regex(theme, "i"); + } + if (group != null && !group.isEmpty()) { + criteria.and("grouping").is(group); + } + if (department != null && !department.isEmpty()) { + criteria.and("dept").regex("^" + Pattern.quote(department) + "$", "i"); + } + + List combinedOrCriteria = new ArrayList<>(); + if (tasks != null && tasks.length > 0) { + for (String task : tasks) { + Criteria taskCriteria = Criteria.where("task").is(task); + combinedOrCriteria.add(taskCriteria); + } + } + + if (includeCommentsOnly) { + List nonEmptyCriteria = createNonEmptyCriteria(); + if (!combinedOrCriteria.isEmpty()) { + List commentCriteriaWithTasks = new ArrayList<>(); + for (Criteria taskCriteria : combinedOrCriteria) { + commentCriteriaWithTasks.add( + new Criteria() + .andOperator( + taskCriteria, + new Criteria().orOperator(nonEmptyCriteria.toArray(new Criteria[0])))); + } + criteria.andOperator( + new Criteria().orOperator(commentCriteriaWithTasks.toArray(new Criteria[0]))); + } else { + criteria.andOperator(new Criteria().orOperator(nonEmptyCriteria.toArray(new Criteria[0]))); + } + } else if (!combinedOrCriteria.isEmpty()) { + criteria.andOperator(new Criteria().orOperator(combinedOrCriteria.toArray(new Criteria[0]))); + } + + return criteria; + } + + private Query buildExportQuery(Criteria criteria) { + Query query = new Query(criteria); + query + .fields() + .include("dateTime") + .include("timeStamp") + .include("surveyReferrer") + .include("language") + .include("device") + .include("screener") + .include("dept") + .include("theme") + .include("themeOther") + .include("grouping") + .include("task") + .include("taskOther") + .include("taskSatisfaction") + .include("taskEase") + .include("taskCompletion") + .include("taskImprove") + .include("taskImproveComment") + .include("taskWhyNot") + .include("taskWhyNotComment") + .include("taskSampling") + .include("samplingInvitation") + .include("samplingGC") + .include("samplingCanada") + .include("samplingTheme") + .include("samplingInstitution") + .include("samplingGrouping") + .include("samplingTask"); + return query; + } + + private String escapeCSV(String value) { + if (value == null) { + return ""; + } + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private List createNonEmptyCriteria() { + List criteriaList = new ArrayList<>(); + criteriaList.add(Criteria.where("taskOther").exists(true).ne("")); + criteriaList.add(Criteria.where("themeOther").exists(true).ne("")); + criteriaList.add(Criteria.where("taskWhyNotComment").exists(true).ne("")); + criteriaList.add(Criteria.where("taskImproveComment").exists(true).ne("")); + return criteriaList; + } + + @RequestMapping(value = "/topTaskSurvey/departments", produces = "application/json") + @ResponseBody + public List> departmentData(HttpServletRequest request) { + String lang = (String) request.getSession().getAttribute("lang"); + + return institutionMappings.entrySet().stream() + .map( + entry -> { + String value = + entry + .getValue() + .get(entry.getValue().size() - 1); // Get the last element (with slashes) + // Keep same format for French + String display = value; // Keep same format for English + + Map departmentMap = new HashMap<>(); + departmentMap.put("value", value); + departmentMap.put("display", display); + return departmentMap; + }) + .sorted( + (a, b) -> a.get("display").compareToIgnoreCase(b.get("display"))) // Sort alphabetically + .collect(Collectors.toList()); + } + + @Autowired private MongoTemplate mongoTemplate; + @Autowired private JWTUtil jwtUtil; + + @GetMapping("/api/toptasks") + public ResponseEntity getProblemsJson( + @RequestParam Map requestParams, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false) String processedStartDate, + @RequestParam(required = false) String processedEndDate, + @RequestParam(required = false) String institution, + @RequestParam(required = false) String url, + @RequestHeader(name = "Authorization") String authorizationHeader) { + String token = null; + String userName = null; + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + token = authorizationHeader.substring(7); + userName = jwtUtil.extractUsername(token); + } + + if (userName != null) { + User user = userService.findUserByEmail(userName); + if (!userService.isAdmin(user) && !userService.isAPI(user)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Access denied. Only API users & Admins can access this endpoint."); + } + } else { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Authorization header is missing or invalid."); + } + + Set validParams = + new HashSet<>( + Arrays.asList( + "startDate", + "endDate", + "processedStartDate", + "processedEndDate", + "institution", + "url", + "authorizationHeader")); + + for (String param : requestParams.keySet()) { + if (!validParams.contains(param)) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Invalid parameter: " + param); + return ResponseEntity.badRequest().body(errorResponse); + } + } + + Criteria criteria = new Criteria("processed").is("true"); + + DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + if ((startDate != null || endDate != null) + && (processedStartDate != null || processedEndDate != null)) { + Map errorResponse = new HashMap<>(); + errorResponse.put( + "error", "You can only filter by normal date range or processed date range, not both."); + return ResponseEntity.badRequest().body(errorResponse); + } + + // Validate and apply normal date range filter + if (startDate != null || endDate != null) { + if (startDate == null || endDate == null) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Both startDate and endDate are required."); + return ResponseEntity.badRequest().body(errorResponse); + } else { + try { + LocalDate start = LocalDate.parse(startDate, dateFormat); + LocalDate end = LocalDate.parse(endDate, dateFormat); + if (end.isBefore(start)) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "endDate must be greater than or equal to startDate."); + return ResponseEntity.badRequest().body(errorResponse); + } + criteria.and("dateTime").gte(startDate).lte(endDate); + } catch (DateTimeParseException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Invalid date format. Please use yyyy-MM-dd."); + return ResponseEntity.badRequest().body(errorResponse); + } + } + } + + // Validate and apply processed date range filter + if (processedStartDate != null || processedEndDate != null) { + if (processedStartDate == null || processedEndDate == null) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Both processedStartDate and processedEndDate are required."); + return ResponseEntity.badRequest().body(errorResponse); + } else { + try { + LocalDate processedStart = LocalDate.parse(processedStartDate, dateFormat); + LocalDate processedEnd = LocalDate.parse(processedEndDate, dateFormat); + if (processedEnd.isBefore(processedStart)) { + Map errorResponse = new HashMap<>(); + errorResponse.put( + "error", "processedEndDate must be greater than or equal to processedStartDate."); + return ResponseEntity.badRequest().body(errorResponse); + } + criteria.and("processedDate").gte(processedStartDate).lte(processedEndDate); + } catch (DateTimeParseException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "Invalid date format. Please use yyyy-MM-dd."); + return ResponseEntity.badRequest().body(errorResponse); + } + } + } + + try { + if (institution != null && !institution.isEmpty()) { + criteria = applyDepartmentFilter(criteria, institution); + } + } catch (IllegalArgumentException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + if (url != null && !url.isEmpty()) { + System.out.println("URL: " + url); + criteria.and("surveyReferrer").regex(url, "i"); + } + Query query = new Query(criteria); + query + .fields() + .exclude("_id") + .exclude("processed") + .exclude("personalInfoProcessed") + .exclude("autoTagProcessed") + .exclude("_class"); + List documents = mongoTemplate.find(query, Document.class, "toptasksurvey"); + return ResponseEntity.ok(documents); + } + + private Criteria applyDepartmentFilter(Criteria criteria, String department) { + // First try direct case-insensitive match for exact database values + criteria.and("dept").regex("^" + Pattern.quote(department) + "$", "i"); + return criteria; + } + + @GetMapping(value = "/topTaskSurvey") + public ModelAndView topTaskSurvey(HttpServletRequest request) throws Exception { + ModelAndView mav = new ModelAndView(); + String lang = (String) request.getSession().getAttribute("lang"); + Map dateMap = problemDateService.getProblemDates(); + mav.setViewName("topTaskSurvey_" + lang); + mav.addObject("earliestDate", "2021-01-14"); + mav.addObject("latestDate", dateMap.get("latestDate")); + + return mav; + } + + @GetMapping("/taskNames") + @ResponseBody + public List getTaskNames(@RequestParam(name = "search", required = false) String search) { + if (search != null && !search.isEmpty()) { + // Use the new repository method to filter page titles based on the search term + return topTaskRepository.findTaskTitlesBySearch(search); + } else { + // Return all page titles if no search term is provided + return topTaskRepository.findDistinctTaskNames(); + } + } + + public UserService getUserService() { + return userService; + } + + public void setUserService(UserService userService) { + this.userService = userService; + } +} diff --git a/src/main/java/ca/gc/tbs/controller/UserController.java b/src/main/java/ca/gc/tbs/controller/UserController.java new file mode 100644 index 00000000..b94cd0a1 --- /dev/null +++ b/src/main/java/ca/gc/tbs/controller/UserController.java @@ -0,0 +1,145 @@ +package ca.gc.tbs.controller; + +import ca.gc.tbs.domain.User; +import ca.gc.tbs.service.UserService; +import java.util.List; +import java.util.stream.Collectors; +import javax.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.springframework.web.servlet.view.RedirectView; + +@Controller +public class UserController { + + private static final Logger LOG = LoggerFactory.getLogger(UserController.class); + + @Autowired private UserService service; + + @GetMapping(value = "/u/update") + public @ResponseBody String updateUser(HttpServletRequest request) { + try { + this.service.enable(request.getParameter("id")); + return "Updated"; + } catch (Exception e) { + return "Error:" + e.getMessage(); + } + } + + @GetMapping(value = "/u/delete") + public @ResponseBody String deleteUser(HttpServletRequest request) { + try { + this.service.deleteUserById(request.getParameter("id")); + return "deleted"; + } catch (Exception e) { + return "Error:" + e.getMessage(); + } + } + + /* TODO check for prod environment and disable */ + @GetMapping(value = "/enableAdmin") + public View enableAdmin(HttpServletRequest request, RedirectAttributes atts) { + try { + this.service.enableAdmin(request.getParameter("email")); + atts.addFlashAttribute("successMessage", "User has been enabled and admin"); + return new RedirectView("/success"); + } catch (Exception e) { + atts.addFlashAttribute("errorMessage", "Failed to enable admin." + e.getMessage()); + return new RedirectView("/error"); + } + } + + public String getData(String lang) { + + String returnData = ""; + try { + StringBuilder builder = new StringBuilder(); + List users = this.service.findAllUsers(); + if (lang.equals("en")) { + for (User user : users) { + builder.append("" + user.getEmail() + ""); + + builder.append("" + user.getInstitution() + ""); + List roles = + user.getRoles().stream().map(role -> role.getRole()).collect(Collectors.toList()); + + builder.append("" + roles + ""); + builder.append("" + user.getDateCreated() + ""); + builder.append("" + (user.isEnabled() ? "Enabled" : "Awaiting approval") + ""); + builder.append("
"); + if (!user.isEnabled()) { + builder.append( + ""); + } else { + builder.append( + ""); + } + builder.append( + ""); + + builder.append("
"); + builder.append(""); + } + } else { + for (User user : users) { + builder.append("" + user.getEmail() + ""); + + builder.append("" + user.getInstitution() + ""); + List roles = + user.getRoles().stream().map(role -> role.getRole()).collect(Collectors.toList()); + + builder.append("" + roles + ""); + builder.append("" + user.getDateCreated() + ""); + builder.append( + "" + (user.isEnabled() ? "Activé" : "En attente d'approbation") + ""); + builder.append("
"); + if (!user.isEnabled()) { + builder.append( + ""); + } else { + builder.append( + ""); + } + builder.append( + ""); + + builder.append("
"); + builder.append(""); + } + } + + returnData = builder.toString(); + } catch (Exception e) { + LOG.error(e.getMessage()); + } + return returnData; + } + + @GetMapping(value = "/u/index") + public ModelAndView dashboard(HttpServletRequest request) throws Exception { + ModelAndView mav = new ModelAndView(); + String lang = (String) request.getSession().getAttribute("lang"); + mav.addObject("data", this.getData(lang)); + mav.setViewName("users_" + lang); + return mav; + } +} diff --git a/src/main/java/ca/gc/tbs/domain/OriginalProblem.java b/src/main/java/ca/gc/tbs/domain/OriginalProblem.java new file mode 100644 index 00000000..5517581b --- /dev/null +++ b/src/main/java/ca/gc/tbs/domain/OriginalProblem.java @@ -0,0 +1,6 @@ +package ca.gc.tbs.domain; + +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "originalproblem") +public class OriginalProblem extends Problem {} diff --git a/src/main/java/ca/gc/tbs/domain/Problem.java b/src/main/java/ca/gc/tbs/domain/Problem.java new file mode 100644 index 00000000..a5099cfe --- /dev/null +++ b/src/main/java/ca/gc/tbs/domain/Problem.java @@ -0,0 +1,273 @@ +package ca.gc.tbs.domain; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; + +public class Problem { + + @Id private String id; + @Indexed private String url; + private int urlEntries; + private String problemDetails; + @Indexed + private String language; + @Indexed private String problemDate; + @Indexed private String timeStamp; + @Indexed private String title; + private String dataOrigin; + private List tags; + // Pipeline fields + @Indexed private String processed; + @Indexed private String airTableSync; + @Indexed private String personalInfoProcessed; + @Indexed private String autoTagProcessed; + @Indexed private String processedDate; // New field for processed date + + @Indexed private String institution; + @Indexed private String theme; + @Indexed private String section; + private String oppositeLang; + private String contact; + @Indexed private String deviceType; + @Indexed private String browser; + + public Problem() { + this.tags = new ArrayList<>(); + } + + public Problem( + String id, + String url, + int urlEntries, + String deviceType, + String browser, + String problemDate, + String timeStamp, + String problemDetails, + String language, + String title, + String institution, + String theme, + String section, + String oppositeLang, + String contact) { + this.id = id; + this.url = url; + this.urlEntries = urlEntries; + this.deviceType = deviceType; + this.browser = browser; + this.problemDetails = problemDetails; + this.problemDate = problemDate; + this.timeStamp = timeStamp; + this.language = language; + this.title = title; + this.institution = institution; + this.theme = theme; + this.section = section; + this.oppositeLang = oppositeLang; + this.contact = contact; + this.tags = new ArrayList<>(); + } + + public Problem(Problem existingProblem) { + this( + existingProblem.id, + existingProblem.url, + existingProblem.urlEntries, + existingProblem.deviceType, + existingProblem.browser, + existingProblem.problemDate, + existingProblem.timeStamp, + existingProblem.problemDetails, + existingProblem.language, + existingProblem.title, + existingProblem.institution, + existingProblem.theme, + existingProblem.section, + existingProblem.oppositeLang, + existingProblem.contact); + this.tags = new ArrayList<>(existingProblem.tags); + } + + // Getter and Setter methods + + // Add getters and setters for all fields + // Example: + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public int getUrlEntries() { + return urlEntries; + } + + public void setUrlEntries(int urlEntries) { + this.urlEntries = urlEntries; + } + + public String getBrowser() { + return browser; + } + + public void setBrowser(String browser) { + this.browser = browser; + } + + public String getDeviceType() { + return deviceType; + } + + public void setDeviceType(String deviceType) { + this.deviceType = deviceType; + } + + public String getProblemDetails() { + return problemDetails; + } + + public void setProblemDetails(String problemDetails) { + this.problemDetails = problemDetails; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getProblemDate() { + return problemDate; + } + + public void setProblemDate(String problemDate) { + this.problemDate = problemDate; + } + + public String getTimeStamp() { + return timeStamp; + } + + public void setTimeStamp(String timeStamp) { + this.timeStamp = timeStamp; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getProcessed() { + return processed; + } + + public void setProcessed(String processed) { + this.processed = processed; + } + + public String getAirTableSync() { + return airTableSync; + } + + public void setAirTableSync(String airTableSync) { + this.airTableSync = airTableSync; + } + + public String getDataOrigin() { + return dataOrigin; + } + + public void setDataOrigin(String dataOrigin) { + this.dataOrigin = dataOrigin; + } + + public String getPersonalInfoProcessed() { + return personalInfoProcessed; + } + + public void setPersonalInfoProcessed(String personalInfoProcessed) { + this.personalInfoProcessed = personalInfoProcessed; + } + + public String getAutoTagProcessed() { + return autoTagProcessed; + } + + public void setAutoTagProcessed(String autoTagProcessed) { + this.autoTagProcessed = autoTagProcessed; + } + + public String getInstitution() { + return institution; + } + + public void setInstitution(String institution) { + this.institution = institution; + } + + public String getTheme() { + return theme; + } + + public void setTheme(String theme) { + this.theme = theme; + } + + public String getSection() { + return section; + } + + public void setSection(String section) { + this.section = section; + } + + public String getOppositeLang() { + return oppositeLang; + } + + public void setOppositeLang(String oppositeLang) { + this.oppositeLang = oppositeLang; + } + + public String getContact() { + return contact; + } + + public void setContact(String contact) { + this.contact = contact; + } + + public String getProcessedDate() { + return processedDate; + } + + public void setProcessedDate(String processedDate) { + this.processedDate = processedDate; + } +} diff --git a/src/main/java/ca/gc/tbs/domain/Role.java b/src/main/java/ca/gc/tbs/domain/Role.java new file mode 100644 index 00000000..c4d0ee8f --- /dev/null +++ b/src/main/java/ca/gc/tbs/domain/Role.java @@ -0,0 +1,29 @@ +package ca.gc.tbs.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +// +@Document(collection = "role") +public class Role { + + @Id private String id; + + private String role; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } +} diff --git a/src/main/java/ca/gc/tbs/domain/TopTaskSurvey.java b/src/main/java/ca/gc/tbs/domain/TopTaskSurvey.java new file mode 100644 index 00000000..e0096df0 --- /dev/null +++ b/src/main/java/ca/gc/tbs/domain/TopTaskSurvey.java @@ -0,0 +1,391 @@ +package ca.gc.tbs.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "toptasksurvey") +@CompoundIndex(def = "{'processed': 1, 'dateTime': 1}") +@CompoundIndex(def = "{'processed': 1, 'dateTime': 1, 'language': 1}") +@CompoundIndex(def = "{'processed': 1, 'dateTime': 1, 'dept': 1}") +@CompoundIndex(def = "{'processed': 1, 'dateTime': 1, 'theme': 1}") +@CompoundIndex(def = "{'processed': 1, 'dateTime': 1, 'grouping': 1}") +@CompoundIndex(def = "{'processed': 1, 'dateTime': 1, 'task': 1}") +@CompoundIndex(def = "{'processed': 1, 'processedDate': 1}") +public class TopTaskSurvey { + @Id private String id = ""; + + // Time-related fields + private String dateTime; + private String timeStamp; + private String processedDate; + + // Survey metadata + private String surveyReferrer; + private String language; + private String device; + private String screener; + + // Organization-related fields + private String dept; + private String theme; + private String themeOther; + private String grouping; + + // Task-related fields + private String task; + private String taskOther; + private String taskSatisfaction; + private String taskEase; + private String taskCompletion; + private String taskImprove; + private String taskImproveComment; + private String taskWhyNot; + private String taskWhyNotComment; + + // Sampling-related fields + private String taskSampling; + private String samplingInvitation; + private String samplingGC; + private String samplingCanada; + private String samplingTheme; + private String samplingInstitution; + private String samplingGrouping; + private String samplingTask; + + // Processing flags + private String processed; + private String topTaskAirTableSync; + private String personalInfoProcessed; + private String autoTagProcessed; + + // Default constructor + public TopTaskSurvey() {} + + // Constructor with all fields + public TopTaskSurvey( + String id, + String dateTime, + String timeStamp, + String processedDate, + String surveyReferrer, + String language, + String device, + String screener, + String dept, + String theme, + String themeOther, + String grouping, + String task, + String taskOther, + String taskSatisfaction, + String taskEase, + String taskCompletion, + String taskImprove, + String taskImproveComment, + String taskWhyNot, + String taskWhyNotComment, + String taskSampling, + String samplingInvitation, + String samplingGC, + String samplingCanada, + String samplingTheme, + String samplingInstitution, + String samplingGrouping, + String samplingTask) { + this.id = id; + this.dateTime = dateTime; + this.timeStamp = timeStamp; + this.processedDate = processedDate; + this.surveyReferrer = surveyReferrer; + this.language = language; + this.device = device; + this.screener = screener; + this.dept = dept; + this.theme = theme; + this.themeOther = themeOther; + this.grouping = grouping; + this.task = task; + this.taskOther = taskOther; + this.taskSatisfaction = taskSatisfaction; + this.taskEase = taskEase; + this.taskCompletion = taskCompletion; + this.taskImprove = taskImprove; + this.taskImproveComment = taskImproveComment; + this.taskWhyNot = taskWhyNot; + this.taskWhyNotComment = taskWhyNotComment; + this.taskSampling = taskSampling; + this.samplingInvitation = samplingInvitation; + this.samplingGC = samplingGC; + this.samplingCanada = samplingCanada; + this.samplingTheme = samplingTheme; + this.samplingInstitution = samplingInstitution; + this.samplingGrouping = samplingGrouping; + this.samplingTask = samplingTask; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDateTime() { + return dateTime; + } + + public void setDateTime(String dateTime) { + this.dateTime = dateTime; + } + + public String getTimeStamp() { + return timeStamp; + } + + public void setTimeStamp(String timeStamp) { + this.timeStamp = timeStamp; + } + + public String getSurveyReferrer() { + return surveyReferrer; + } + + public void setSurveyReferrer(String surveyReferrer) { + this.surveyReferrer = surveyReferrer; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getDevice() { + return device; + } + + public void setDevice(String device) { + this.device = device; + } + + public String getScreener() { + return screener; + } + + public void setScreener(String screener) { + this.screener = screener; + } + + public String getDept() { + return dept; + } + + public void setDept(String dept) { + this.dept = dept; + } + + public String getThemeOther() { + return themeOther; + } + + public void setThemeOther(String themeOther) { + this.themeOther = themeOther; + } + + public String getTheme() { + return theme; + } + + public void setTheme(String theme) { + this.theme = theme; + } + + public String getGrouping() { + return grouping; + } + + public void setGrouping(String grouping) { + this.grouping = grouping; + } + + public String getTask() { + return task; + } + + public void setTask(String task) { + this.task = task; + } + + public String getTaskOther() { + return taskOther; + } + + public void setTaskOther(String taskOther) { + this.taskOther = taskOther; + } + + public String getTaskSatisfaction() { + return taskSatisfaction; + } + + public void setTaskSatisfaction(String taskSatisfaction) { + this.taskSatisfaction = taskSatisfaction; + } + + public String getTaskEase() { + return taskEase; + } + + public void setTaskEase(String taskEase) { + this.taskEase = taskEase; + } + + public String getTaskCompletion() { + return taskCompletion; + } + + public void setTaskCompletion(String taskCompletion) { + this.taskCompletion = taskCompletion; + } + + public String getTaskImprove() { + return taskImprove; + } + + public void setTaskImprove(String taskImprove) { + this.taskImprove = taskImprove; + } + + public String getTaskImproveComment() { + return taskImproveComment; + } + + public void setTaskImproveComment(String taskImproveComment) { + this.taskImproveComment = taskImproveComment; + } + + public String getTaskWhyNot() { + return taskWhyNot; + } + + public void setTaskWhyNot(String taskWhyNot) { + this.taskWhyNot = taskWhyNot; + } + + public String getTaskWhyNotComment() { + return taskWhyNotComment; + } + + public void setTaskWhyNotComment(String taskWhyNotComment) { + this.taskWhyNotComment = taskWhyNotComment; + } + + public String getTaskSampling() { + return taskSampling; + } + + public void setTaskSampling(String sampling) { + this.taskSampling = sampling; + } + + public String getSamplingInvitation() { + return samplingInvitation; + } + + public void setSamplingInvitation(String samplingInvitation) { + this.samplingInvitation = samplingInvitation; + } + + public String getSamplingGC() { + return samplingGC; + } + + public void setSamplingGC(String samplingGC) { + this.samplingGC = samplingGC; + } + + public String getSamplingCanada() { + return samplingCanada; + } + + public void setSamplingCanada(String samplingCanada) { + this.samplingCanada = samplingCanada; + } + + public String getSamplingTheme() { + return samplingTheme; + } + + public void setSamplingTheme(String samplingTheme) { + this.samplingTheme = samplingTheme; + } + + public String getSamplingInstitution() { + return samplingInstitution; + } + + public void setSamplingInstitution(String samplingInstitution) { + this.samplingInstitution = samplingInstitution; + } + + public String getSamplingGrouping() { + return samplingGrouping; + } + + public void setSamplingGrouping(String samplingGrouping) { + this.samplingGrouping = samplingGrouping; + } + + public String getSamplingTask() { + return samplingTask; + } + + public void setSamplingTask(String samplingTask) { + this.samplingTask = samplingTask; + } + + public String getProcessed() { + return processed; + } + + public void setProcessed(String processed) { + this.processed = processed; + } + + public String getTopTaskAirTableSync() { + return topTaskAirTableSync; + } + + public void setTopTaskAirTableSync(String topTaskAirTableSync) { + this.topTaskAirTableSync = topTaskAirTableSync; + } + + public String getPersonalInfoProcessed() { + return personalInfoProcessed; + } + + public void setPersonalInfoProcessed(String personalInfoProcessed) { + this.personalInfoProcessed = personalInfoProcessed; + } + + public String getAutoTagProcessed() { + return autoTagProcessed; + } + + public void setAutoTagProcessed(String autoTagProcessed) { + this.autoTagProcessed = autoTagProcessed; + } + + public String getProcessedDate() { + return processedDate; + } + + public void setProcessedDate(String processedDate) { + this.processedDate = processedDate; + } +} diff --git a/src/main/java/ca/gc/tbs/domain/User.java b/src/main/java/ca/gc/tbs/domain/User.java new file mode 100644 index 00000000..dc296deb --- /dev/null +++ b/src/main/java/ca/gc/tbs/domain/User.java @@ -0,0 +1,76 @@ +package ca.gc.tbs.domain; + +import java.util.Set; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.DBRef; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "user") +public class User { + + @Id private String id; + private String email; + private String password; + private String institution; + private boolean enabled; + + private String dateCreated; + + @DBRef private Set roles; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDateCreated() { + return dateCreated; + } + + public void setDateCreated(String dateCreated) { + this.dateCreated = dateCreated; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public String getInstitution() { + return institution; + } + + public void setInstitution(String institution) { + this.institution = institution; + } +} diff --git a/src/main/java/ca/gc/tbs/filter/LanguageFilter.java b/src/main/java/ca/gc/tbs/filter/LanguageFilter.java new file mode 100644 index 00000000..8b2389f1 --- /dev/null +++ b/src/main/java/ca/gc/tbs/filter/LanguageFilter.java @@ -0,0 +1,78 @@ +package ca.gc.tbs.filter; + +import java.io.IOException; +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class LanguageFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest req = (HttpServletRequest) request; + HttpSession session = req.getSession(); + + String lang = getSelectedLang(req); + + session.setAttribute("lang", lang.equals("en") ? "en" : "fr"); + + // build alt lang + String altLang; + String altLangText; + if (lang.equals("en")) { + altLang = "fr"; + altLangText = "Français"; + } else { + altLang = "en"; + altLangText = "English"; + } + + String requestURL = req.getRequestURL().toString(); + String queryString = cleanQueryStringLangParam(req, lang); + + if (queryString.equals("")) { + requestURL = requestURL + "?lang=" + altLang; + } else { + requestURL = requestURL + "?" + queryString + "&lang=" + altLang; + } + + session.setAttribute("langUrl", requestURL); + session.setAttribute("altLang", altLang); + session.setAttribute("altLangText", altLangText); + + chain.doFilter(request, response); + } + + private String cleanQueryStringLangParam(HttpServletRequest req, String lang) { + String queryString = req.getQueryString(); + if (queryString == null) { + return ""; + } + + queryString = queryString.replaceAll("(^|&)lang=" + lang + "($|&)", "&"); + queryString = queryString.replaceAll("&&+", "&"); + queryString = queryString.replaceAll("^&|&$", ""); + + return queryString; + } + + private String getSelectedLang(HttpServletRequest req) { + String lang = (String) req.getSession().getAttribute("lang"); + + // if lang query param set -> set to selected language + String langParam = req.getParameter("lang"); + if (langParam != null) { + lang = langParam; + } + // find default if needed + if (lang == null) { + lang = LocaleContextHolder.getLocale().getLanguage(); + } + return lang; + } +} diff --git a/src/main/java/ca/gc/tbs/repository/CustomTopTaskRepository.java b/src/main/java/ca/gc/tbs/repository/CustomTopTaskRepository.java new file mode 100644 index 00000000..e1540728 --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/CustomTopTaskRepository.java @@ -0,0 +1,9 @@ +package ca.gc.tbs.repository; + +import java.util.List; +import java.util.Map; +import org.springframework.data.mongodb.core.query.Criteria; + +public interface CustomTopTaskRepository { + List findDistinctTaskCountsWithFilters(Criteria criteria); +} diff --git a/src/main/java/ca/gc/tbs/repository/CustomTopTaskRepositoryImpl.java b/src/main/java/ca/gc/tbs/repository/CustomTopTaskRepositoryImpl.java new file mode 100644 index 00000000..a1b8231f --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/CustomTopTaskRepositoryImpl.java @@ -0,0 +1,36 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.TopTaskSurvey; // Import your domain class +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; + +public class CustomTopTaskRepositoryImpl implements CustomTopTaskRepository { + + private final MongoTemplate mongoTemplate; + + @Autowired + public CustomTopTaskRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public List findDistinctTaskCountsWithFilters(Criteria criteria) { + Aggregation aggregation = + Aggregation.newAggregation( + Aggregation.match(criteria), // Apply the filter criteria + Aggregation.project("task"), // Include only the 'task' field in the projection + Aggregation.group( + "task") // Group by the 'task' field, effectively getting distinct tasks + ); + + // Assuming 'TopTaskSurvey' is the domain class representing your collection + AggregationResults results = + mongoTemplate.aggregate(aggregation, TopTaskSurvey.class, Map.class); + return results.getMappedResults(); + } +} diff --git a/src/main/java/ca/gc/tbs/repository/OriginalProblemRepository.java b/src/main/java/ca/gc/tbs/repository/OriginalProblemRepository.java new file mode 100644 index 00000000..6dfa02e9 --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/OriginalProblemRepository.java @@ -0,0 +1,6 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.OriginalProblem; +import org.springframework.data.mongodb.datatables.DataTablesRepository; + +public interface OriginalProblemRepository extends DataTablesRepository {} diff --git a/src/main/java/ca/gc/tbs/repository/ProblemRepository.java b/src/main/java/ca/gc/tbs/repository/ProblemRepository.java new file mode 100644 index 00000000..31144b72 --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/ProblemRepository.java @@ -0,0 +1,134 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.Problem; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import javax.validation.Valid; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.datatables.DataTablesInput; +import org.springframework.data.mongodb.datatables.DataTablesOutput; +import org.springframework.data.mongodb.datatables.DataTablesRepository; +import org.springframework.data.mongodb.repository.Aggregation; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProblemRepository extends DataTablesRepository { + default DataTablesOutput findAllWithErrorKeywords( + @Valid DataTablesInput input, Set keywords, Criteria baseCriteria) { + if (!keywords.isEmpty()) { + // Combine all keywords into a single regex pattern + String combinedPattern = + keywords.stream() + .map(Pattern::quote) + .reduce((a, b) -> a + "|" + b) + .map(p -> "(" + p + ")") + .orElse(""); + + // Add a single regex criteria for all keywords + baseCriteria.and("problemDetails").regex(combinedPattern, "i"); + } + return findAll(input, baseCriteria); + } + + List findByAirTableSync(String syncd); + + List findByProcessed(String processed); + + List findByPersonalInfoProcessed(String processed); + + List findByAutoTagProcessed(String processed); + + @Aggregation( + pipeline = { + "{ '$match': { 'processed': 'true' } }", // Optional, adjust based on your requirements + "{ '$project': { " + + "'_id': 0, " + + // Excludes the _id field + "'url': 1, " + + // Includes the url field + "'problemDate': 1, " + + // Includes the problemDate field + "'institution': 1, " + + // Includes the institution field + "'title': 1, " + + // Includes the title field + "'language': 1, " + + // Includes the language field + "'section': 1, " + + // Includes the section field + "'theme': 1 " + + // Includes the theme field + "}}" + }) + List findAllProcessedProblems(); + + @Aggregation( + pipeline = { + // Optional match stage + "{ '$match': { 'processed': 'true' } }", + "{ '$group': { " + + "'_id': { 'url': '$url', 'day': { '$substr': ['$problemDate', 0, 10] } }, " + + "'count': { '$sum': 1 }, " + + "'institution': { '$first': '$institution' }, " + + "'title': { '$first': '$title' }, " + + "'problemDate': { '$first': '$problemDate' }, " + + "'language': { '$first': '$language' }, " + + "'section': { '$first': '$section' }, " + + "'theme': { '$first': '$theme' } " + + "}}, ", + "{ '$project': { " + + "'url': '$_id.url', " + + "'day': '$_id.day', " + + "'_id': 0, " + + "'count': 1, " + + "'institution': 1, " + + "'title': 1, " + + "'problemDate': 1, " + + "'language': 1, " + + "'section': 1, " + + "'theme': 1 " + + "}}" + }) + AggregationResults findDistinctUrlsWithDetails(); + + List findByProcessedAndInstitution(String processed, String institution); + + @Aggregation(pipeline = {"{ '$group': { '_id' : '$url' } }"}) + DataTablesOutput findDistinctUrls(@Valid DataTablesInput input); + + // New method to find the earliest and latest problemDate + + @Aggregation( + pipeline = { + "{ '$group': { '_id': null, 'earliestDate': { '$min': '$problemDate' }, 'latestDate': {" + + " '$max': '$problemDate' } } }", + "{ '$project': { '_id': 0, 'earliestDate': 1, 'latestDate': 1 } }" + }) + AggregationResults findEarliestAndLatestProblemDate(); + + @Aggregation( + pipeline = { + "{ '$match': { 'processed': 'true' } }", // Optional, adjust based on your requirements + "{ '$group': { '_id': '$title' } }", + "{ '$sort': { '_id': 1 } }" // Optional, sorts the page names alphabetically + }) + List findDistinctPageNames(); + + @Aggregation( + pipeline = { + "{ '$match': { 'processed': 'true', 'title': { '$regex': ?0, '$options': 'i' } } }", + "{ '$group': { '_id': '$title' } }", + "{ '$sort': { '_id': 1 } }" + }) + List findPageTitlesBySearch(String search); + @Aggregation( + pipeline = { + "{ '$match': { 'processed': 'true' } }", + "{ '$group': { '_id': '$url' } }", + "{ '$project': { '_id': 0, 'url': '$_id' } }" + }) + List findDistinctProcessedUrls(); +} diff --git a/src/main/java/ca/gc/tbs/repository/RoleRepository.java b/src/main/java/ca/gc/tbs/repository/RoleRepository.java new file mode 100644 index 00000000..f6b76609 --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/RoleRepository.java @@ -0,0 +1,9 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.Role; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface RoleRepository extends MongoRepository { + + Role findByRole(String role); +} diff --git a/src/main/java/ca/gc/tbs/repository/TopTaskRepository.java b/src/main/java/ca/gc/tbs/repository/TopTaskRepository.java new file mode 100644 index 00000000..b787179f --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/TopTaskRepository.java @@ -0,0 +1,34 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.TopTaskSurvey; +import java.util.List; +import org.springframework.data.mongodb.datatables.DataTablesRepository; +// import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Aggregation; + +public interface TopTaskRepository + extends DataTablesRepository, CustomTopTaskRepository { + List findByTopTaskAirTableSync(String syncd); + + List findByProcessed(String processed); + + List findByPersonalInfoProcessed(String processed); + + List findByAutoTagProcessed(String processed); + + @Aggregation( + pipeline = { + "{ '$match': { 'processed': 'true', 'task': { '$regex': ?0, '$options': 'i' } } }", + "{ '$group': { '_id': '$task' } }", + "{ '$sort': { '_id': 1 } }" + }) + List findTaskTitlesBySearch(String search); + + @Aggregation( + pipeline = { + "{ '$match': { 'processed': 'true' } }", + "{ '$project': { 'task': 1 } }", // Include only the 'task' field + "{ '$group': { '_id': '$task' } }", + }) + List findDistinctTaskNames(); +} diff --git a/src/main/java/ca/gc/tbs/repository/UserRepository.java b/src/main/java/ca/gc/tbs/repository/UserRepository.java new file mode 100644 index 00000000..9613baa1 --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/UserRepository.java @@ -0,0 +1,13 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.Role; +import ca.gc.tbs.domain.User; +import java.util.List; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface UserRepository extends MongoRepository, UserRepositoryCustom { + + User findByEmail(String email); + + List findByRolesContaining(Role role); +} diff --git a/src/main/java/ca/gc/tbs/repository/UserRepositoryCustom.java b/src/main/java/ca/gc/tbs/repository/UserRepositoryCustom.java new file mode 100644 index 00000000..6054fe59 --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/UserRepositoryCustom.java @@ -0,0 +1,7 @@ +package ca.gc.tbs.repository; + +import java.util.List; + +public interface UserRepositoryCustom { + public List findAllInstitutions(); +} diff --git a/src/main/java/ca/gc/tbs/repository/UserRepositoryCustomImpl.java b/src/main/java/ca/gc/tbs/repository/UserRepositoryCustomImpl.java new file mode 100644 index 00000000..dde4f77c --- /dev/null +++ b/src/main/java/ca/gc/tbs/repository/UserRepositoryCustomImpl.java @@ -0,0 +1,19 @@ +package ca.gc.tbs.repository; + +import ca.gc.tbs.domain.Problem; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; + +public class UserRepositoryCustomImpl implements UserRepositoryCustom { + + @Autowired MongoTemplate mongoTemplate; + + public UserRepositoryCustomImpl() {} + + public List findAllInstitutions() { + List instList = + mongoTemplate.query(Problem.class).distinct("institution").as(String.class).all(); + return instList; + } +} diff --git a/src/main/java/ca/gc/tbs/security/JWTFilter.java b/src/main/java/ca/gc/tbs/security/JWTFilter.java new file mode 100644 index 00000000..25ea15f8 --- /dev/null +++ b/src/main/java/ca/gc/tbs/security/JWTFilter.java @@ -0,0 +1,71 @@ +package ca.gc.tbs.security; + +import ca.gc.tbs.service.UserService; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JWTFilter extends OncePerRequestFilter { + + @Autowired private JWTUtil jwtUtil; + + @Autowired private UserService userService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authorizationHeader = request.getHeader("Authorization"); + + String token = null; + String userName = null; + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + token = authorizationHeader.substring(7); + try { + userName = jwtUtil.extractUsername(token); + } catch (RuntimeException ex) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write(ex.getMessage()); + return; + } + } + + if (userName != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userService.loadUserByUsername(userName); + if (jwtUtil.validateToken(token, userDetails)) { + List authorities = + ((List) jwtUtil.extractClaim(token, claims -> claims.get("authorities"))) + .stream().map(Object::toString).collect(Collectors.toList()); + if (authorities.contains("ADMIN") || authorities.contains("API")) { + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + usernamePasswordAuthenticationToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + } else { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response + .getWriter() + .write("Access denied. Only API users & Admins can access this endpoint."); + return; + } + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/ca/gc/tbs/security/JWTUtil.java b/src/main/java/ca/gc/tbs/security/JWTUtil.java new file mode 100644 index 00000000..b8204d30 --- /dev/null +++ b/src/main/java/ca/gc/tbs/security/JWTUtil.java @@ -0,0 +1,76 @@ +package ca.gc.tbs.security; + +import io.jsonwebtoken.*; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +@Service +public class JWTUtil { + @Value("${jwt.secret.key}") + private String SECRET_KEY; + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + try { + return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody(); + } catch (MalformedJwtException ex) { + // Handle malformed JWT token + throw new RuntimeException("Invalid token: Malformed JWT token", ex); + } catch (ExpiredJwtException + | UnsupportedJwtException + | SignatureException + | IllegalArgumentException ex) { + // Handle other exceptions related to JWT token validation + throw new RuntimeException("Invalid token: " + ex.getMessage(), ex); + } + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public String generateToken(UserDetails userDetails) { + Map claims = new HashMap<>(); + List authorities = + userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + claims.put("authorities", authorities); + return createToken(claims, userDetails.getUsername()); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) + .signWith(SignatureAlgorithm.HS256, SECRET_KEY) + .compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} diff --git a/src/main/java/ca/gc/tbs/service/BadWords.java b/src/main/java/ca/gc/tbs/service/BadWords.java new file mode 100644 index 00000000..e2fa7d5b --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/BadWords.java @@ -0,0 +1,114 @@ +package ca.gc.tbs.service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +public class BadWords { + private static final Logger logger = LoggerFactory.getLogger(BadWords.class); + + private static final Set words = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Set allowedWords = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final String[] DEFAULT_FILES = { + "static/badwords/badwords_en.txt", "static/badwords/badwords_fr.txt", + // "static/badwords/threats_fr.txt", + // "static/badwords/threats_en.txt" + }; + private static final String ALLOWED_WORDS_FILE = "static/badwords/allowed_words.txt"; + + public static void loadConfigs() { + for (String file : DEFAULT_FILES) { + loadFileConfigs(file); + } + loadGoogleConfigs( + "https://docs.google.com/spreadsheets/d/1hIEi2YG3ydav1E06Bzf2mQbGZ12kh2fe4ISgLg_UBuM/export?format=csv"); + loadAllowedWords(ALLOWED_WORDS_FILE); + logger.info("Loaded {} words to filter out", words.size()); + logger.info("Loaded {} allowed words that will not be filtered", allowedWords.size()); + } + + private static void loadAllowedWords(String filePath) { + try { + Resource resource = new ClassPathResource(filePath, BadWords.class.getClassLoader()); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + allowedWords.addAll( + reader.lines().map(String::trim).map(String::toLowerCase).collect(Collectors.toSet())); + } + } catch (Exception e) { + logger.warn("Allowed words file {} not found, creating empty set", filePath); + // If file doesn't exist yet, start with an empty set but add CARM as default + allowedWords.add("carm"); + } + } + + private static void loadFileConfigs(String filePath) { + try { + Resource resource = new ClassPathResource(filePath, BadWords.class.getClassLoader()); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + words.addAll( + reader.lines().map(String::trim).map(String::toLowerCase).collect(Collectors.toSet())); + } + } catch (Exception e) { + logger.error("Error loading file config {}", filePath, e); + } + } + + private static void loadGoogleConfigs(String googleSheetUrl) { + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(new URL(googleSheetUrl).openConnection().getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + words.add(line.trim().split(",")[0].toLowerCase()); // assuming first column has the word + } + } catch (IOException e) { + logger.error("Error loading Google config from {}", googleSheetUrl, e); + } + } + + /** + * Returns the set of allowed words that should not be redacted. + * This is used by other services that need to know which words to exclude from redaction. + */ + public static Set getAllowedWords() { + return Collections.unmodifiableSet(allowedWords); + } + + public static String censor(String text) { + StringBuilder result = new StringBuilder(); + for (String word : text.split("\\s+")) { + String wordToCheck = + word.toLowerCase() + .replaceAll("[^a-zà-ÿ]", ""); // Including accented characters for French + + // Skip censoring if the word is in the allowed words list + boolean shouldCensor = words.stream().anyMatch(wordToCheck::contains) && + !allowedWords.contains(wordToCheck); + + result + .append(shouldCensor ? createMask(word) : word) + .append(' '); + } + return result.toString().trim(); + } + + private static String createMask(String word) { + return word.replaceAll(".", "*"); + } +} diff --git a/src/main/java/ca/gc/tbs/service/ContentService.java b/src/main/java/ca/gc/tbs/service/ContentService.java new file mode 100644 index 00000000..7998a94e --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/ContentService.java @@ -0,0 +1,174 @@ +package ca.gc.tbs.service; + +import edu.stanford.nlp.pipeline.CoreDocument; +import edu.stanford.nlp.pipeline.CoreEntityMention; +import edu.stanford.nlp.pipeline.StanfordCoreNLP; +import java.util.*; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Service +public class ContentService { + + // Pre-compiled regex patterns for better performance + private static final Pattern POSTAL_CODE_PATTERN = + Pattern.compile("[A-Za-z]\\s*\\d\\s*[A-Za-z]\\s*[ -]?\\s*\\d\\s*[A-Za-z]\\s*\\d"); + private static final Pattern PASSPORT_PATTERN = Pattern.compile("\\b([A-Za-z]{2}\\s*\\d{6})\\b"); + private static final Pattern SIN_PATTERN = + Pattern.compile("(\\d{3}\\s*\\d{3}\\s*\\d{3}|\\d{3}\\D*\\d{3}\\D*\\d{3})"); + private static final Pattern PHONE_PATTERN_1 = + Pattern.compile("(\\+\\d{1,2}\\s?)?1?\\-?\\.?\\s?\\(?\\d{3}\\)?[\\s.-]?\\d{3}[\\s.-]?\\d{4}"); + private static final Pattern PHONE_PATTERN_2 = + Pattern.compile( + "(?:(?:\\+?1\\s*(?:[.-]\\s*)?)?(?:\\(\\s*([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\\s*\\)|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\\s*(?:[.-]\\s*)?)?([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\\s*(?:[.-]\\s*)?([0-9]{4})(?:\\s*(?:#|x\\.?|ext\\.?|extension)\\s*(\\d+))?"); + private static final Pattern EMAIL_PATTERN = + Pattern.compile("([a-zA-Z0-9_\\-\\.]+)\\s*@([\\sa-zA-Z0-9_\\-\\.]+)[\\.\\,]([a-zA-Z]{1,5})"); + + // Singleton NLP pipeline for better performance + private static final StanfordCoreNLP nlpPipeline; + + // Initialize the NLP pipeline once + static { + Properties props = new Properties(); + props.setProperty("annotators", "tokenize,ssplit,pos,lemma,ner"); + nlpPipeline = new StanfordCoreNLP(props); + } + + // Set of allowed words that should never be redacted, loaded from BadWords + private Set allowedWords; + + public ContentService() { + System.out.println("attempting to load bad words config..."); + BadWords.loadConfigs(); + // Get the allowed words from BadWords class + this.allowedWords = BadWords.getAllowedWords(); + } + + public String cleanContent(String content) { + if (content.isEmpty()) { + return content; // Return empty string if content is empty + } + content = StringUtils.normalizeSpace(content); + String newContent = BadWords.censor(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("curse words cleaned: " + content); + } + newContent = this.cleanPostalCode(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("Postal code cleaned: " + content); + } + newContent = this.cleanPhoneNumber(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("Phone number cleaned: " + content); + } + newContent = this.cleanPassportNumber(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("Passport number cleaned: " + content); + } + newContent = this.cleanSIN(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("SIN number cleaned: " + content); + } + newContent = this.cleanEmailAddress(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("Email Address cleaned: " + content); + } + newContent = this.cleanNames(content); + if (!newContent.contentEquals(content)) { + content = newContent; + System.out.println("Names cleaned: " + content); + } + return content; + } + + /** Cleans postal codes from the content. */ + private String cleanPostalCode(String content) { + return POSTAL_CODE_PATTERN.matcher(content).replaceAll("### ###"); + } + + /** Cleans passport numbers from the content. */ + private String cleanPassportNumber(String content) { + return PASSPORT_PATTERN.matcher(content).replaceAll("## ######"); + } + + /** Cleans SIN numbers from the content. */ + private String cleanSIN(String content) { + return SIN_PATTERN.matcher(content).replaceAll("### ### ###"); + } + + /** Cleans phone numbers from the content. */ + private String cleanPhoneNumber(String content) { + content = PHONE_PATTERN_1.matcher(content).replaceAll("# ### ### ###"); + content = PHONE_PATTERN_2.matcher(content).replaceAll("# ### ### ###"); + return content; + } + + /** Cleans email addresses from the content. */ + private String cleanEmailAddress(String content) { + return EMAIL_PATTERN.matcher(content).replaceAll("####@####.####"); + } + + /** + * Cleans the names of persons in the provided content by replacing them with '#' characters. Uses + * StanfordCoreNLP for natural language processing and entity recognition. Reverses the list of + * entity mentions to replace them from the end of the string, preserving the indices of earlier + * mentions when replacing later ones. + */ + public String cleanNames(String content) { + try { + CoreDocument doc = new CoreDocument(content); + nlpPipeline.annotate(doc); + + List entityMentions = new ArrayList<>(doc.entityMentions()); + Collections.reverse(entityMentions); + + StringBuilder sb = new StringBuilder(content); + + Set commonPronouns = + new HashSet<>(Arrays.asList("he", "she", "him", "her", "his", "hers")); + + for (CoreEntityMention em : entityMentions) { + if (em.entityType().equals("PERSON")) { + String mentionText = em.text().toLowerCase(); + String pos = em.tokens().get(0).tag(); + + // Skip if it's a common pronoun, if its POS tag is a pronoun (PRP or PRP$), + // or if it's in our allowed words list + boolean isAllowed = allowedWords.contains(mentionText); + + // Also check if any word in the mention is in the allowed list + // This handles multi-word names where one part might be allowed + if (!isAllowed && mentionText.contains(" ")) { + for (String word : mentionText.split("\\s+")) { + if (allowedWords.contains(word)) { + isAllowed = true; + break; + } + } + } + + if (!commonPronouns.contains(mentionText) && !pos.startsWith("PRP") && !isAllowed) { + int start = em.charOffsets().first(); + int end = em.charOffsets().second(); + char[] replacement = new char[end - start]; + Arrays.fill(replacement, '#'); + sb.replace(start, end, new String(replacement)); + } + } + } + + return sb.toString(); + } catch (Exception e) { + System.out.println("Error during NLP processing: " + e.getMessage()); + // Return original content if NLP processing fails + return content; + } + } +} diff --git a/src/main/java/ca/gc/tbs/service/EmailService.java b/src/main/java/ca/gc/tbs/service/EmailService.java new file mode 100644 index 00000000..6fc59b69 --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/EmailService.java @@ -0,0 +1,80 @@ +package ca.gc.tbs.service; + +import ca.gc.tbs.domain.User; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import uk.gov.service.notify.NotificationClient; + +@Service +public class EmailService { + + @Value("${notify.templateid.accountenabled}") + private String userActivationRequestKey; + + @Value("${notify.templateid.useractivationrequest}") + private String accountEnabledKey; + + @Value("${pagesuccess.loginURL}") + private String loginURL; + + @Autowired private UserService userService; + + public String getUserActivationRequestKey() { + return userActivationRequestKey; + } + + public void setUserActivationRequestKey(String userActivationRequestKey) { + this.userActivationRequestKey = userActivationRequestKey; + } + + public String getAccountEnabledKey() { + return accountEnabledKey; + } + + public void setAccountEnabledKey(String accountEnabledKey) { + this.accountEnabledKey = accountEnabledKey; + } + + public NotificationClient getNotificationClient() { + return new NotificationClient(getAPIKey(), "https://api.notification.alpha.canada.ca"); + } + + private String getAPIKey() { + try { + File file = + new File( + getClass() + .getClassLoader() + .getResource("static/secrets/notification.secret") + .getFile()); + return new String( + Files.readAllBytes(Paths.get(file.getCanonicalPath())), StandardCharsets.UTF_8); + } catch (Exception e) { + + } + return ""; + } + + public void sendUserActivationRequestEmail(String email) { + Map personalisation = new HashMap<>(); + personalisation.put("email", email); + personalisation.put("loginURL", loginURL); + List admins = this.userService.findUserByRole(UserService.ADMIN_ROLE); + for (User user : admins) { + try { + this.getNotificationClient() + .sendEmail(this.userActivationRequestKey, user.getEmail(), personalisation, ""); + } catch (Exception e) { + System.out.println(e.getMessage()); + } + } + } +} diff --git a/src/main/java/ca/gc/tbs/service/ErrorKeywordService.java b/src/main/java/ca/gc/tbs/service/ErrorKeywordService.java new file mode 100644 index 00000000..92b77831 --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/ErrorKeywordService.java @@ -0,0 +1,142 @@ +package ca.gc.tbs.service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.*; +import java.util.regex.Pattern; +import javax.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +@Service +public class ErrorKeywordService { + private static final Logger LOG = LoggerFactory.getLogger(ErrorKeywordService.class); + + // Pre-compiled patterns for each language + private Pattern englishPattern; + private Pattern frenchPattern; + private Pattern bilingualPattern; + + // Keep original keywords for logging and debugging + private Set englishKeywords = new HashSet<>(); + private Set frenchKeywords = new HashSet<>(); + private Set bilingualKeywords = new HashSet<>(); + + public Set getEnglishKeywords() { + return englishKeywords; + } + + public Set getFrenchKeywords() { + return frenchKeywords; + } + + public Set getBilingualKeywords() { + return bilingualKeywords; + } + + @PostConstruct + public void init() { + try { + LOG.info("Starting to load error keywords..."); + + // Load keywords + englishKeywords = loadKeywords("static/error_keywords/errors_en.txt"); + frenchKeywords = loadKeywords("static/error_keywords/errors_fr.txt"); + bilingualKeywords = loadKeywords("static/error_keywords/errors_bilingual.txt"); + + // Pre-compile patterns + englishPattern = compilePattern(englishKeywords); + frenchPattern = compilePattern(frenchKeywords); + bilingualPattern = compilePattern(bilingualKeywords); + + LOG.info( + "Successfully loaded and compiled patterns. Total keywords - English: {}, French: {}," + + " Bilingual: {}", + englishKeywords.size(), + frenchKeywords.size(), + bilingualKeywords.size()); + + // Log samples for verification + logSampleKeywords("English", englishKeywords); + logSampleKeywords("French", frenchKeywords); + logSampleKeywords("Bilingual", bilingualKeywords); + + } catch (Exception e) { + LOG.error("Failed to initialize error keywords", e); + throw e; + } + } + + private void logSampleKeywords(String type, Set keywords) { + LOG.info( + "Loaded {} keywords. Size: {}, Sample: {}", + type, + keywords.size(), + new ArrayList<>(keywords).subList(0, Math.min(5, keywords.size()))); + } + + private Pattern compilePattern(Set keywords) { + if (keywords.isEmpty()) { + return Pattern.compile("$^"); // Pattern that matches nothing + } + + // Join all keywords with OR operator and compile once + String pattern = + keywords.stream() + .map(Pattern::quote) + .reduce((a, b) -> a + "|" + b) + .map(p -> "(?i)(" + p + ")") + .orElse("$^"); + + return Pattern.compile(pattern); + } + + private Set loadKeywords(String path) { + Set keywords = new HashSet<>(); + try { + ClassPathResource resource = new ClassPathResource(path); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(resource.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty()) { + keywords.add(line.trim().toLowerCase()); + } + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to load error keywords from " + path, e); + } + return keywords; + } + + public boolean containsErrorKeywords(String text, String language) { + if (text == null || text.isEmpty()) { + LOG.debug("Text is null or empty"); + return false; + } + + text = text.toLowerCase(); + LOG.debug("Checking text for error keywords: {}", text); + LOG.debug("Language: {}", language); + + // Check bilingual keywords first using pre-compiled pattern + if (bilingualPattern.matcher(text).find()) { + LOG.debug("Found bilingual keyword match"); + return true; + } + + // Check language-specific keywords using pre-compiled pattern + Pattern languagePattern = "fr".equalsIgnoreCase(language) ? frenchPattern : englishPattern; + if (languagePattern.matcher(text).find()) { + LOG.debug("Found {} keyword match", language); + return true; + } + + LOG.debug("No error keywords found in text"); + return false; + } +} diff --git a/src/main/java/ca/gc/tbs/service/ProblemCacheService.java b/src/main/java/ca/gc/tbs/service/ProblemCacheService.java new file mode 100644 index 00000000..bf02bbab --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/ProblemCacheService.java @@ -0,0 +1,38 @@ +package ca.gc.tbs.service; + +import ca.gc.tbs.domain.Problem; +import ca.gc.tbs.repository.ProblemRepository; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +public class ProblemCacheService { + + private static final Logger LOGGER = LoggerFactory.getLogger(ProblemCacheService.class); + @Autowired private ProblemRepository problemRepository; + + @Scheduled(cron = "0 0 0 * * *") + @CacheEvict(value = {"distinctUrls", "processedProblems"}, allEntries = true) + public void clearCacheDaily() { + LOGGER.info("Evicting all caches at midnight"); + } + + @Cacheable("processedProblems") + public List getProcessedProblems() { + LOGGER.info("Fetching all processed problems from repository (cache miss or initial load)."); + return problemRepository.findAllProcessedProblems(); + } + + + @Cacheable("distinctUrls") + public List getDistinctProcessedUrlsForCache() { + LOGGER.info("Fetching distinct processed URLs from repository (cache miss or initial load)."); + return problemRepository.findDistinctProcessedUrls(); + } +} diff --git a/src/main/java/ca/gc/tbs/service/ProblemDateService.java b/src/main/java/ca/gc/tbs/service/ProblemDateService.java new file mode 100644 index 00000000..e88cc85b --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/ProblemDateService.java @@ -0,0 +1,66 @@ +package ca.gc.tbs.service; + +import java.time.LocalDate; +import java.time.Month; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class ProblemDateService { + + private static final Logger logger = LoggerFactory.getLogger(ProblemDateService.class); + + @Cacheable(value = "problemDates", key = "'all'", unless = "#result == null") + public Map getProblemDates() { + logger.info("Calculating problem dates based on fiscal quarters (cache miss or initial load)."); + LocalDate currentDate = LocalDate.now(); + LocalDate earliestDate; + LocalDate latestDate; + + int currentMonth = currentDate.getMonthValue(); + int currentYear = currentDate.getYear(); + + // Determine the current fiscal quarter and calculate the date range + if (currentMonth >= Month.APRIL.getValue() && currentMonth <= Month.JUNE.getValue()) { + // Q1 (April 1 - June 30) - Show Q4 (previous year) and Q1 (current year) + earliestDate = LocalDate.of(currentYear - 1, Month.OCTOBER, 1); + latestDate = LocalDate.of(currentYear, Month.JUNE, 30); + } else if (currentMonth >= Month.JULY.getValue() && currentMonth <= Month.SEPTEMBER.getValue()) { + // Q2 (July 1 - September 30) - Show Q1 and Q2 + earliestDate = LocalDate.of(currentYear, Month.APRIL, 1); + latestDate = LocalDate.of(currentYear, Month.SEPTEMBER, 30); + } else if (currentMonth >= Month.OCTOBER.getValue() && currentMonth <= Month.DECEMBER.getValue()) { + // Q3 (October 1 - December 31) - Show Q2 and Q3 + earliestDate = LocalDate.of(currentYear, Month.JULY, 1); + latestDate = LocalDate.of(currentYear, Month.DECEMBER, 31); + } else { + // Q4 (January 1 - March 31) - Show Q3 (previous year) and Q4 (current year) + earliestDate = LocalDate.of(currentYear - 1, Month.JULY, 1); + latestDate = LocalDate.of(currentYear, Month.MARCH, 31); + } + + Map resultMap = new HashMap<>(); + resultMap.put("earliestDate", earliestDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); + resultMap.put("latestDate", latestDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); + + logger.info("Calculated problem dates: {}", resultMap); + return resultMap; + } + + // The clearCacheDaily and refreshProblemDates methods are no longer needed as dates are calculated + // @Scheduled(cron = "0 0 0 * * *") // Runs every day at midnight UTC + // @CacheEvict(value = "problemDates", allEntries = true) + // public void clearCacheDaily() { + // logger.info("Clearing problemDates cache at {}", ZonedDateTime.now(ZoneOffset.UTC)); + // } + + // @CacheEvict(value = "problemDates", allEntries = true) + // public void refreshProblemDates() { + // logger.info("Manually refreshing problemDates cache at {}", ZonedDateTime.now(ZoneOffset.UTC)); + // } +} diff --git a/src/main/java/ca/gc/tbs/service/UserService.java b/src/main/java/ca/gc/tbs/service/UserService.java new file mode 100644 index 00000000..626814f1 --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/UserService.java @@ -0,0 +1,151 @@ +// UserService.java +package ca.gc.tbs.service; + +import ca.gc.tbs.domain.Role; +import ca.gc.tbs.domain.User; +import ca.gc.tbs.repository.RoleRepository; +import ca.gc.tbs.repository.UserRepository; +import java.text.SimpleDateFormat; +import java.util.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class UserService implements UserDetailsService { + + public static final String USER_ROLE = "USER"; + public static final String ADMIN_ROLE = "ADMIN"; + public static final String API_ROLE = "API"; + + public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd"); + + @Autowired private UserRepository userRepository; + @Autowired private RoleRepository roleRepository; + + @Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; + + public User findUserByEmail(String email) { + return userRepository.findByEmail(email); + } + + public User findUserById(String Id) { + return userRepository.findById(Id).get(); + } + + public List findUserByRole(String role) { + Role oRole = this.roleRepository.findByRole(role); + return userRepository.findByRolesContaining(oRole); + } + + public void deleteUserById(String Id) { + userRepository.deleteById(Id); + } + + public List findAllUsers() { + return userRepository.findAll(); + } + + public List findInstitutions() { + return userRepository.findAllInstitutions(); + } + + public User getCurrentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = + ((org.springframework.security.core.userdetails.User) auth.getPrincipal()).getUsername(); + return this.findUserByEmail(username); + } + + public void saveUser(User user) { + user.setPassword(bCryptPasswordEncoder.encode(user.getPassword())); + user.setDateCreated(DATE_FORMAT.format(new Date())); + Role userRole = null; + if (this.userRepository.count() <= 0) { + user.setEnabled(true); + userRole = roleRepository.findByRole(ADMIN_ROLE); + } else { + userRole = roleRepository.findByRole(USER_ROLE); + } + user.setRoles(new HashSet<>(Arrays.asList(userRole))); + userRepository.save(user); + } + + public void saveApiUser(User user) { + user.setPassword(bCryptPasswordEncoder.encode(user.getPassword())); + user.setDateCreated(DATE_FORMAT.format(new Date())); + Role apiRole = roleRepository.findByRole(API_ROLE); + user.setRoles(new HashSet<>(Arrays.asList(apiRole))); + userRepository.save(user); + } + + public Role findRoleByName(String roleName) { + return roleRepository.findByRole(roleName); + } + + public boolean isAdmin(User user) { + for (Role role : user.getRoles()) { + if (role.getRole().contentEquals(ADMIN_ROLE)) { + return true; + } + } + return false; + } + + public boolean isAPI(User user) { + for (Role role : user.getRoles()) { + if (role.getRole().contentEquals(API_ROLE)) { + return true; + } + } + return false; + } + + public void enable(String id) { + User user = this.findUserById(id); + user.setEnabled(true); + userRepository.save(user); + } + + public void enableAdmin(String email) { + User user = this.findUserByEmail(email); + user.setRoles(new HashSet<>(Arrays.asList(roleRepository.findByRole(ADMIN_ROLE)))); + user.setEnabled(true); + userRepository.save(user); + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + + User user = userRepository.findByEmail(email); + if (user != null && user.isEnabled()) { + List authorities = getUserAuthority(user.getRoles()); + return buildUserForAuthentication(user, authorities); + } else { + throw new UsernameNotFoundException("username not found"); + } + } + + private List getUserAuthority(Set userRoles) { + Set roles = new HashSet<>(); + userRoles.forEach( + (role) -> { + roles.add(new SimpleGrantedAuthority(role.getRole())); + }); + + List grantedAuthorities = new ArrayList<>(roles); + return grantedAuthorities; + } + + private UserDetails buildUserForAuthentication(User user, List authorities) { + return new org.springframework.security.core.userdetails.User( + user.getEmail(), user.getPassword(), authorities); + } +} diff --git a/src/main/resources/application.properties.gpg b/src/main/resources/application.properties.gpg new file mode 100644 index 0000000000000000000000000000000000000000..dd6890af89dbadfd8a682224e95d861fb0645c12 GIT binary patch literal 604 zcmV-i0;Bzm4Fm}T2!3pehI`X42jMzU9Q*%7>pya&i9;PA2^b4`AxCbN2Q9i#iI5K_r`W^UkOm>Z~>yF`%bIgZwYv z^_fKxw8#uTG@)1Io{$|SpFcwf;8t&cLMW0II7C6Oszx92cMgm`Q;Ykrv%L2;+^rml~& z1DnSZe35pClvB1^EH#+FW^~+iKT%+jRo4u)wUNCqc1hoKH?i}bGh7Z}q47=TGL8tg z{cPaul9tAYx55EQ@;_xoIe?a)(6xJorkC#wovZ8a(!g~?f`(euJk-c7i*#c_gNmfo=T{5%sA*?!5FGq zXg~VoDD{TY+uH<6;7(vT*`V4DnrnzYGBxZ+4Z!OS>9fL475q|CyAPZHpBX2mW* q9C&Z;c&UMRu6^b4A+*O*b%($toW?XTlCgO#i^T;zaxef tbody > tr > *, +table.dataTable.display > tbody > tr > * { + border-top: 1px solid rgba(0, 0, 0, 0.15); +} +table.dataTable > thead > tr > th, +table.dataTable > thead > tr > td { + padding: 10px; + border-bottom: 6px solid rgba(0, 0, 0, 1); +} + +table.dataTable.display tbody td, +table.dataTable.display tbody th, +table.dataTable.rowborder tbody td, +table.dataTable.rowborder tbody th { + border-top: 3px solid #d8d8d8 !important; +} + +.feedback-tool-data { + width: 85%; /* Or your desired width */ + padding: 1em; + border: 1px solid #333; + border-radius: 6px; + margin-top: 2ch; + margin-left: auto; /* Center the div horizontally */ + margin-right: auto; /* Center the div horizontally */ + margin-bottom: 4ch; +} + +.feedback-tool label { + font-weight: bold; +} + +.feedback-tool label + label { + margin-top: 1ch; +} + +.feedback-tool > h2 { + grid-column: 1 / 1; + margin-top: 0.4em; + /* font-weight: normal; */ +} + +.feedback-tool > :not(.download-section):not(h2) { + grid-column: 2 / span 2; + display: grid; + grid-template-columns: subgrid; + align-items: center; +} + +.feedback-tool > details > label { + display: grid; + grid-template-columns: subgrid; + grid-column: span 2; +} +#myTable tbody td { + font-size: 14px; /* Set the font size for data rows */ +} +.feedback-tool input, +.feedback-tool select { + width: 70% !important; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + + +.download-section { + text-align: right; + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + grid-column: 3; + padding: 0; + margin: 0; +} + +.download-section > button + button { + margin-left: 1em; +} + +#downloadCSV, +#downloadExcel { + padding: 10px 20px; + margin-top: 10px; + border: 1px solid #dcdee1; + border-radius: 4px; +} + +#filterDetails { + margin: 1em 0; + width: 100%; + grid-column: 2 / span 2 !important; + display: block !important; +} + +.feedback-tool > details { + margin-top: 1em; +} + +#filterDetails > p { + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 1em; + gap: 1em; + margin: 0; +} + +/* Error comments checkbox styling */ +#filterDetails label:has(#errorComments) { + display: flex; + align-items: center; + padding: 0.75em 1em; + margin: 0.5em 1em; + transition: all 0.2s ease-in-out; +} + +#errorComments { + width: auto !important; + margin-right: 0.75em; + transform: scale(1.2); + cursor: pointer; +} + +#filterDetails > p > label { + display: flex; + flex-flow: row nowrap; + width: 100%; + align-items: center; + gap: 0.5em; + margin: 0; +} + +#filterDetails select { + margin-left: 1em; + width: 70%; +} + +.feedback-tool input, +.feedback-tool select { + width: max-content; +} + +#dateRange option { + padding: 5px; + /* Adjust the padding as needed */ +} + +.feedback-tool > details > p { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +.feedback-tool > details > p > label { + display: flex; + flex-flow: row nowrap; + width: 100%; + align-items: center; +} + +.feedback-tool > details select { + margin-left: 1em; +} + +.spacer { + margin: 0 2em; +} + +summary { + cursor: pointer; +} + +summary { + display: list-item !important; + list-style-type: none; + list-style-type: disclosure-closed; + visibility: visible !important; +} + +details .out details summary, +details:not([open]) details summary { + display: none !important; +} + +details { + margin-bottom: 0.25em; +} + +details summary { + border: 1px solid #ddd; + border-radius: 4px; + color: #000000; + padding: 5px 15px; +} + +#dateRange .separator::after { + content: " "; + display: block; + height: 10px; +} + +details summary:focus, +details summary:hover { + background-color: transparent; + color: #000000; + text-decoration: underline; +} + +details summary:focus { + outline-style: dotted; + outline-width: 1px; +} +main { + /* font-size: 16px !important;*/ + line-height: 1em !important; +} +.dataTables_paginate { + font-size: 16px; /* Adjust the font size as needed */ +} +.dataTables_info { + font-size: 16px; /* Adjust the font size as needed */ +} +.dataTables_length label { + font-weight: 400; /* Make the text bold */ + font-size: 16px; /* Adjust the font size */ + margin-top: 2pt; +} +.feedback-tool a:visited { + color: #284162; +} +.feedback-tool .glyphicon { + opacity: 0.5; + padding-left: 6px; + margin-bottom: 3px; +} +.custom-row { + margin-left: 0; + margin-right: 0; +} +.pagedetails { + max-width: 1140px; /* Adjust based on your main container's width */ + margin-right: auto; + margin-left: auto; +} + +details[open] { + border: 1px solid #ddd; + border-radius: 4px; +} + +details:not([open]) { + visibility: hidden; +} + +details:not([open]) > *, +details:not([open]) > details { + display: none; +} +.dt-buttons { + display: none; +} +.feedback-tool-header { + display: flex; + justify-content: flex-end; + /* Align items to the right */ + position: absolute; + /* Absolute position */ + right: 1em; + /* Same as padding to align with the form fields */ + top: 1em; + /* Same as padding from the top */ +} + +/* Adjust the space for total entries info after the table bottom border */ +#myTable_info { + margin-top: 18px; + margin-bottom: 10px; +} + +/* space between the dropdown choice and label in the table entries*/ +label[for="dt-length-0"] { + margin-left: 10px; +} + +.reset-filters { + cursor: pointer; + text-decoration: underline; + white-space: nowrap; +} diff --git a/src/main/resources/static/css/pageFeedback_styles.css b/src/main/resources/static/css/pageFeedback_styles.css new file mode 100644 index 00000000..1180b21d --- /dev/null +++ b/src/main/resources/static/css/pageFeedback_styles.css @@ -0,0 +1,308 @@ +/* Base styles */ +body { + font-family: "Noto sans", sans-serif; +} + +main { + line-height: 1em !important; +} + +/* Header styles */ +h1#wb-cont { + border-bottom: 5px solid #af3c43; + border-image: linear-gradient(to right, #af3c43 71px, transparent 71px); + border-image-slice: 1; +} + +/* Feedback tool container */ +.feedback-tool { + display: grid; + grid-template-columns: 0.5fr 0.5fr 3fr; + grid-auto-rows: auto; + position: relative; + padding: 1em 0 2em; + margin-top: 2ch; +} + +.feedback-tool > h2 { + grid-column: 1 / 1; + margin-top: 0.4em; +} + +.feedback-tool > :not(.download-section):not(h2) { + grid-column: 2 / span 2; + display: grid; + grid-template-columns: subgrid; + align-items: center; +} + +.feedback-tool > details > label { + display: grid; + grid-template-columns: subgrid; + grid-column: span 2; +} + +.feedback-tool-data { + width: 85%; + padding: 1em; + border: 1px solid #333; + border-radius: 6px; + margin: 2ch auto 4ch; +} + +/* Form elements */ +.feedback-tool .form-group { + margin-bottom: 10px; +} + +.feedback-tool label { + font-weight: bold; +} + +.feedback-tool label + label { + margin-top: 1ch; +} + +.feedback-tool input, +.feedback-tool select { + width: 70% !important; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.feedback-tool .ss-main { + width: 70%; +} + +/* Button styles */ +.feedback-tool .btn-unstyled { + background-color: white; + border: none; + margin: 7px 0 0 2em; + font-size: 90%; +} + +.feedback-tool .btn-unstyled:hover { + color: #3a00d1; +} + +.download-section { + grid-column: 3; + display: flex; + justify-content: flex-end; + padding: 0; + margin: 0; +} + +.download-section > button + button { + margin-left: 1em; +} + +#downloadCSV, +#downloadExcel { + padding: 10px 20px; + margin-top: 10px; + border: 1px solid #dcdee1; + border-radius: 4px; +} + +/* Details element styles */ +#filterDetails { + margin: 1em 0; + width: 100%; + grid-column: 2 / span 2 !important; + display: block !important; +} + +#filterDetails > p { + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 0.5em 1em; + gap: 1em; + margin: 0; +} + +#filterDetails > p:first-of-type { + padding-top: 1em; +} + +#filterDetails > p:last-of-type { + padding-bottom: 1em; +} + +/* Error comments checkbox styling */ +#filterDetails label:has(#errorComments) { + display: flex; + align-items: center; + padding: 0.75em 1em; + margin: 0.5em 1em; + transition: all 0.2s ease-in-out; +} + +#errorComments { + width: auto !important; + margin-right: 0.75em; + transform: scale(1.2); + cursor: pointer; +} + +#filterDetails > p > label { + display: flex; + flex-flow: row nowrap; + flex: 1; + align-items: center; + gap: 0.5em; + margin: 0; +} + +#filterDetails select { + flex: 1; + min-width: 200px; + max-width: 300px; +} + +details { + margin-bottom: 0.25em; +} + +details[open] { + border: 1px solid #ddd; + border-radius: 4px; +} + +details:not([open]) > *:not(summary) { + display: none; +} + +details summary { + display: list-item !important; + list-style-type: disclosure-closed; + border: 1px solid #ddd; + border-radius: 4px; + color: #000; + padding: 5px 15px; + cursor: pointer; +} + +details summary:focus, +details summary:hover { + background-color: transparent; + color: #000; + text-decoration: underline; +} + +details summary:focus { + outline-style: dotted; + outline-width: 1px; +} + +/* Table styles */ +table.dataTable.compact tbody td { + padding: 4px; +} + +table.dataTable > thead > tr > th, +table.dataTable > thead > tr > td { + padding: 10px; + border-bottom: 6px solid rgba(0, 0, 0, 1); +} + +table.dataTable.display tbody td, +table.dataTable.display tbody th, +table.dataTable.rowborder tbody td, +table.dataTable.rowborder tbody th { + border-top: 3px solid #d8d8d8 !important; +} + +#myTable tbody td { + font-size: 14px; +} + +/* DataTables specific styles */ +.dataTables_paginate, +.dataTables_info, +.dataTables_length label { + font-size: 16px; +} + +.dataTables_length label { + font-weight: 400; + margin-top: 2pt; +} + +.dt-buttons { + display: none; +} + +/* Date picker styles */ +.daterangepicker .ranges li.active, +.daterangepicker td.active, +.daterangepicker td.active:hover { + background-color: #26374a; +} + +#drp, +.ss-main { + margin-bottom: 10px !important; + height: 38px; +} + +.ss-main .ss-values .ss-placeholder { + font-weight: normal; + color: #797979; +} + +.ss-main .ss-arrow { + height: 9px; + width: 9px; + margin-right: -0.01em; +} + +/* Utility classes */ +.spacer { + padding: 0 1.5em; + font-weight: bold; + color: #666; + white-space: nowrap; +} + +.reset-filters { + cursor: pointer; + text-decoration: underline; + white-space: nowrap; +} + +.custom-row { + margin-left: 0; + margin-right: 0; +} + +.pagedetails { + max-width: 1140px; + margin-right: auto; + margin-left: auto; +} + +/* Link and icon styles */ +.feedback-tool a:visited { + color: #284162; +} + +.feedback-tool .glyphicon { + opacity: 0.5; + padding-left: 6px; + margin-bottom: 3px; +} + +/* Adjust the space for total entries info after the table bottom border */ +#myTable_info { + margin-top: 18px; + margin-bottom: 10px; +} + +/* space between the dropdown choice and label in the table entries*/ +label[for="dt-length-0"] { + margin-left: 10px; +} diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 00000000..123212c6 --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,62 @@ +/* +To change this license header, choose License Headers in Project Properties. +To change this template file, choose Tools | Templates +and open the template in the editor. +*/ +/* + Created on : Jun 21, 2018, 9:26:51 PM + Author : didin +*/ +html, +body { + height: 100%; +} + +body { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; + background-color: #f5f5f5; +} + +.form-signin { + width: 100%; + max-width: 330px; + padding: 15px; + margin: auto; +} + +.form-signin .checkbox { + font-weight: 400; +} + +.form-signin .form-control { + position: relative; + box-sizing: border-box; + height: auto; + padding: 10px; + font-size: 16px; +} + +.form-signin .form-control:focus { + z-index: 2; +} + +.form-signin input[type="email"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.form-signin input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.form-signin input[type="text"] { + margin-bottom: 10px; +} \ No newline at end of file diff --git a/src/main/resources/static/css/topTaskSurvey_styles.css b/src/main/resources/static/css/topTaskSurvey_styles.css new file mode 100644 index 00000000..caeb5b21 --- /dev/null +++ b/src/main/resources/static/css/topTaskSurvey_styles.css @@ -0,0 +1,412 @@ +.dt-button { + align-items: flex-start; + font-size: 15px; + text-align: center; + visibility: visible; +} + +body { + font-family: "Noto sans", sans-serif; +} +/* .loading-spinner-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} */ + +.loading-spinner { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} + +.loading-spinner i { + margin-bottom: 10px; + color: #007bff; /* Change this to the desired color for the spinner */ +} +.daterangepicker .ranges li.active { + background-color: #26374a; +} +.daterangepicker td.active, +.daterangepicker td.active:hover { + background-color: #26374a; +} + +h1#wb-cont { + border-bottom: 5px solid #af3c43; + -o-border-image: linear-gradient(to right, #af3c43 71px, transparent 71px); + border-image: linear-gradient(to right, #af3c43 71px, transparent 71px); + border-image-slice: 1; + /* margin-top:12px; */ +} +.tss .btn-unstyled { + background-color: white; + margin-top: 7px; + border: none; + margin-left: 2em; + font-size: 90%; +} +.tss .btn-unstyled:hover { + color: #3a00d1; +} +.tss { + padding: 1em 0em 2em; + /* border: 5px solid #000000; */ + margin-top: 2ch; + display: grid; + grid-template-columns: 0.5fr 0.5fr 3fr; + grid-auto-rows: auto; + position: relative; + /* Add position relative to the feedback tool */ +} +.tss .form-group { + margin-bottom: 10px; +} + +#drp, +.ss-main { + margin-bottom: 10px !important; + height: 38px; +} +.tss .ss-main { + width: 70%; +} + +.tss .ss-main .ss-values .ss-placeholder { + font-weight: normal; + color: #797979; +} + +.tss .ss-main .ss-arrow { + height: 9px; + width: 9px; + margin-right: -0.01em; +} +table.dataTable.compact tbody td { + padding: 4px; +} +table.dataTable.row-border > tbody > tr > *, +table.dataTable.display > tbody > tr > * { + border-top: 1px solid rgba(0, 0, 0, 0.15); +} +table.dataTable > thead > tr > th, +table.dataTable > thead > tr > td { + padding: 10px; + border-bottom: 6px solid rgba(0, 0, 0, 1); +} + +table.dataTable.display tbody td, +table.dataTable.display tbody th, +table.dataTable.rowborder tbody td, +table.dataTable.rowborder tbody th { + border-top: 3px solid #d8d8d8 !important; +} + +.tss-data { + width: 85%; /* Or your desired width */ + padding: 1em; + border: 1px solid #333; + border-radius: 6px; + margin-top: 2ch; + margin-left: auto; /* Center the div horizontally */ + margin-right: auto; /* Center the div horizontally */ + margin-bottom: 4ch; +} + +.tss label { + font-weight: bold; +} + +.tss label + label { + margin-top: 1ch; +} + +.tss > h2 { + grid-column: 1 / 1; + margin-top: 0.4em; + /* font-weight: normal; */ +} + +.tss > :not(.download-section):not(h2) { + grid-column: 2 / span 2; + display: grid; + grid-template-columns: subgrid; + align-items: center; +} + +.tss > details > label { + display: grid; + grid-template-columns: subgrid; + grid-column: span 2; +} +#myTable tbody td { + font-size: 14px; /* Set the font size for data rows */ +} +.tss input, +.tss select { + width: 70% !important; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.download-section { + text-align: right; + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + grid-column: 3; + padding: 0; + margin: 0; +} + +.download-section > button + button { + margin-left: 1em; +} + +#downloadCSV, +#downloadExcel { + padding: 10px 20px; + margin-top: 10px; + border: 1px solid #dcdee1; + border-radius: 4px; +} + +.tss > details > summary ~ * { + display: grid; + grid-template-columns: 1fr 3fr; +} + +.tss > details { + margin-top: 1em; +} + +.tss input, +.tss select { + width: max-content; +} + +#dateRange option { + padding: 5px; + /* Adjust the padding as needed */ +} + +.tss > details > p { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +.tss > details > p > label { + display: flex; + flex-flow: row nowrap; + width: 100%; + align-items: center; +} + +.tss > details select { + margin-left: 1em; +} + +.spacer { + margin: 0 2em; +} + +summary { + cursor: pointer; +} + +summary { + display: list-item !important; + list-style-type: none; + list-style-type: disclosure-closed; + visibility: visible !important; +} + +details .out details summary, +details:not([open]) details summary { + display: none !important; +} + +details { + margin-bottom: 0.25em; +} + +details summary { + border: 1px solid #ddd; + border-radius: 4px; + color: #000000; + padding: 5px 15px; +} + +#dateRange .separator::after { + content: " "; + display: block; + height: 10px; +} + +details summary:focus, +details summary:hover { + background-color: transparent; + color: #000000; + text-decoration: underline; +} + +details summary:focus { + outline-style: dotted; + outline-width: 1px; +} +main { + /* font-size: 16px !important;*/ + line-height: 1em !important; +} +.dataTables_paginate { + font-size: 16px; /* Adjust the font size as needed */ +} +.dataTables_info { + font-size: 16px; /* Adjust the font size as needed */ +} +.dataTables_length label { + font-weight: 400; /* Make the text bold */ + font-size: 16px; /* Adjust the font size */ + margin-top: 2pt; +} +.tss a:visited { + color: #284162; +} +.tss .glyphicon { + opacity: 0.5; + padding-left: 6px; + margin-bottom: 3px; +} +.custom-row { + margin-left: 0; + margin-right: 0; +} + + +details[open] { + border: 1px solid #ddd; + border-radius: 4px; +} + +details:not([open]) { + visibility: hidden; +} + +details:not([open]) > *, +details:not([open]) > details { + display: none; +} +.dt-buttons { + display: none; +} +.tss-header { + display: flex; + justify-content: flex-end; + /* Align items to the right */ + position: absolute; + /* Absolute position */ + right: 1em; + /* Same as padding to align with the form fields */ + top: 1em; + /* Same as padding from the top */ +} + +.reset-filters { + cursor: pointer; + text-decoration: underline; + white-space: nowrap; +} +.stats-box { + display: flex; + flex-wrap: wrap; /* Allows items to wrap onto the next line if needed */ + justify-content: center; /* Centers items horizontally in the container */ + align-items: center; + padding: 20px; + border: 1px solid #000; + margin: 1em auto; + max-width: 85%; +} + +.stats-box .stat { + text-align: center; + /* Adjust 'flex' to not grow or shrink and set a basis if needed, or remove it entirely */ + margin: 0 5rem; /* Adds margin to the left and right of each .stat element */ + /* You may remove 'flex: 1' if you want to control the width explicitly */ +} + + +.stats-box .totalTaskCount { + display: block; + font-size: 2em; + font-weight: bold; + margin-bottom: 0.5em; +} + +.stats-box .totalDistinctTasks { + display: block; + font-size: 2em; + font-weight: bold; + margin-bottom: 0.5em; +} +.pagedetails { + max-width: 1140px; /* Adjust based on your main container's width */ + margin-right: auto; + margin-left: auto; +} + + +.stats-box .description { + font-size: 18px; + font-weight: bold; + color: #333; + display: block; /* Ensures that the description is on its own line */ + margin: auto; /* This will center the description in the flex container */ + max-width: 25rem; /* Or whatever value you wish */ +} + +.feedback-tool-data { /*The table for task completion data*/ + border: 1px solid #000; + max-width: 85%; + margin: 1em auto; + padding: 20px; + border-radius: 0; + width: 85%; +} + +/* Adjust the space for total entries info after the table bottom border */ +#topTaskTable { + margin-top: 30px; + margin-bottom: 18px; +} + +#topTaskTable tbody td { + font-size: 10pt !important; +} + +/* Adjust the space for total entries/info after the table bottom border */ +#topTaskTable_info { + margin-top: 18px; + margin-bottom: 10px; +} +/*Space between the dropdown entries choice and entries per page*/ +label[for="dt-length-0"] { + margin-left: 10px; +} + + + diff --git a/src/main/resources/static/error_keywords/errors_bilingual.txt b/src/main/resources/static/error_keywords/errors_bilingual.txt new file mode 100644 index 00000000..db9dd7a4 --- /dev/null +++ b/src/main/resources/static/error_keywords/errors_bilingual.txt @@ -0,0 +1,7 @@ +403 +404 +chrome +firefox +ERR. +ERR_ +cache \ No newline at end of file diff --git a/src/main/resources/static/error_keywords/errors_en.txt b/src/main/resources/static/error_keywords/errors_en.txt new file mode 100644 index 00000000..eb4abe63 --- /dev/null +++ b/src/main/resources/static/error_keywords/errors_en.txt @@ -0,0 +1,208 @@ +access denied +access page +available +be saved +been down +blank page +blocked +booted me +broken +browser +bug +bumped out +button is disabled +cache +can't access +cannot access +can not move forward +can not submit +can not open +can't open +couldn't select +can't sign in +cannot log in +can't submit +can't upload +can not get past +can't get past +can't load +cannot load +could not access +can not access +cannot submit +cannot upload +capatcha +could not +could not move on +couldn’t be submitted +couldn't submit +couldn't upload +crash +dead end +declined +did not match +didn't show +does not function +doesn't function +did not connect +did not load +did not function +did not work +didn't work +does not work +doesn't connect +doesn't work +doesnt work +down again +error +error message +err. +fails +failed +finish loading +freezing +froze +freezes +frozen +getting a fault +glitch +greyed out +hang +hanging +hung +I can't see +insecure site +isn't connecting +it won't allow me +it wouldn't allow me +kicked out +let me submit +locked up +long loading +loop +lost connection +maintenance +never able to +network issue +never working +not functioning +not available +not upload +no working +not able to check +not coming up +nothing happens +no success +not compatible +not letting me +not save +not saving +not worked +not working +non-functional +not allowing me +not functional +nothing happened +out of service +page does not exist +page doesn't exist +page is not available +page isn't working +page redirects me +page says loading +page will not load +page will not open +page not found +page not available +proxy +re-direct +redirect +redirected +reset +scrolling +secure connection failed +server +Service not available +service unavailable +site has been down +site is down +site down +site too slow +slow system +spinning dial +spins +stalls +system locked +system not available +something went wrong +something was wrong +spinning +spinning wheel +stuck +submit button +system down +system says +system failure +system wasn't available +system won't +system did not respond +technical problem +temporary down +to no avail +time out +time to load +timed out +timed-out +times out +technical difficulties +timed me out +timeout +timing out +technical issue +there is no way +try again later +typo +unable to access +unable to advance +unable to get +unable to submit +unable to update +unavailable +under construction +under maintenance +unresponsive +was down +website won't accept +would not display +would not let me +would not load +wouldn't accept +wasn't available +wasn't working +website is down +website problem +website is down +website was down +why can't I +why cant I +will not accept +will not work +will not go through +won't let me +won't load +won't submit +won't work +would not allow me +would not open +would not load +would not accept +wouldn't accept +wouldn't complete +wouldn't load +wouldn't work +wrong +you don't accept +your system + + + diff --git a/src/main/resources/static/error_keywords/errors_fr.txt b/src/main/resources/static/error_keywords/errors_fr.txt new file mode 100644 index 00000000..4e36b40c --- /dev/null +++ b/src/main/resources/static/error_keywords/errors_fr.txt @@ -0,0 +1,134 @@ +a échoué +accès refusé +bloqué +bloque +blocage +boucle +bouton d'envoi +cassé +chargement impossible +chargement trop long +connexion impossible +connexion perdue +décliné +disponible +défilement +défaillance du système +délai d'attente expiré +échoue +expulsé +échec de la connexion sécurisée +en construction +en maintenance +en panne +erreur +err. +être sauvegardé +fin du chargement +fonctionne pas +gelé +grisé +hors service +il n'y a aucun moyen +impossible à charger +Impossible d'accéder +Impossible de charger +impossible de passer +impasse +impossible d'envoyer +impossible d'obtenir +impossible d'ouvrir +impossible de mettre à jour +impossible de sélectionner +incorrect +indisponible temporairement +impossible de vérifier +indisponible +la page indique qu'elle est en cours de chargement +la page ne s'ouvre pas +le bouton est désactivé +le système n'a pas répondu +m'a bloqué +marche pas +me laisser soumettre +message d'erreur +n'accepte pas +n'acceptera pas +n'achèvera pas +navigateur +ne chargera pas +ne me laisse pas faire +ne me laisse pas +ne s'affiche pas +ne fonctionne jamais +ne fonctionne pas +ne pas me laisser +ne pas sauver +ne peut être soumis +ne peut pas avancer +ne peut pas avancer +ne peut pas envoyer +ne peut pas soumettre +ne peut pas télécharger +ne se charge pas +ne se connecte pas +ne se soumet pas +ne s'affiche pas +ne travaillera pas +ne fonctionnait pas +ne me permet pas d'accéder +ne passe pas +ne s'est pas chargé +ne s'ouvre pas +ne se charge pas +non fonctionnel +page blanche +page me redirige +page ne fonctionne pas +page ne se charge pas +problèmes techniques +Page indisponible +page non trouvée +page inexistante +problème réseau +problème technique +pas compatible +pas de sauvegarde +pas de succès +pas de travail +pas disponible +pas en mesure d'avancer +pas fonctionné +pas le droit +peux pas voir +pourquoi je ne peux pas +problème de site web +quelque chose n'allait pas +redirigé +rediriger +réessayez plus tard +refuserait d'accepter +rien ne se passe +rotation du cadran +réinitialiser +rien ne s'est passé +roue qui tourne +sans réponse +serveur +Service non disponible +site indisponible +site non sécurisé +site trop lent +site web n'accepte pas +suspension +système indisponible +système lent +système verrouillé +télécharger +temps d'attente +temps de chargement +temps mort +tourne +votre système +vous n'acceptez pas +verrouillé \ No newline at end of file diff --git a/src/main/resources/static/js/loading-overlay.js b/src/main/resources/static/js/loading-overlay.js new file mode 100644 index 00000000..614d4317 --- /dev/null +++ b/src/main/resources/static/js/loading-overlay.js @@ -0,0 +1,325 @@ +/** + * DataTable Loading Overlay Manager + * Provides a modern, full-page loading indicator for DataTables filtering and processing + * + * Usage: + * const loadingOverlay = new DataTableLoadingOverlay(options); + * loadingOverlay.show(); + * loadingOverlay.hide(); + * + * Or use the factory function: + * const overlay = createDataTableLoadingOverlay(options); + */ + +class DataTableLoadingOverlay { + constructor(options = {}) { + this.options = { + loadingText: options.loadingText || 'Loading data...', + subtext: options.subtext || 'Please wait while we filter your results', + timeoutMessage: options.timeoutMessage || 'This is taking longer than expected. You can close this dialog if needed.', + spinnerType: options.spinnerType || 'spinner', // 'spinner' or 'dots' + zIndex: options.zIndex || 9999, + showTimeoutAfter: options.showTimeoutAfter || 8000, // Show timeout message after 8 seconds + allowDismiss: options.allowDismiss !== false, // Allow dismissing by default + miniLoadingText: options.miniLoadingText || 'Loading...', + ...options + }; + + this.overlay = null; + this.miniIndicator = null; + this.isVisible = false; + this.isLoading = false; + this.timeoutTimer = null; + this.init(); + } + + /** + * Initialize the overlay and inject it into the DOM + */ + init() { + const existingOverlay = document.getElementById('datatable-loading-overlay'); + if (existingOverlay) { + this.overlay = existingOverlay; + // Re-attach close button handler if it exists + const closeBtn = this.overlay.querySelector('.datatable-loading-close'); + if (closeBtn) { + closeBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.hideFull(); + }; + } + } else { + this.overlay = this.createOverlay(); + document.body.appendChild(this.overlay); + } + + // Initialize mini indicator + const existingMini = document.getElementById('datatable-loading-mini'); + if (existingMini) { + this.miniIndicator = existingMini; + } else { + this.miniIndicator = this.createMiniIndicator(); + document.body.appendChild(this.miniIndicator); + } + } + + /** + * Create the overlay DOM structure + */ + createOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'datatable-loading-overlay'; + overlay.className = 'datatable-loading-overlay'; + overlay.setAttribute('role', 'alert'); + overlay.setAttribute('aria-live', 'assertive'); + overlay.setAttribute('aria-busy', 'true'); + overlay.style.zIndex = this.options.zIndex; + + const content = document.createElement('div'); + content.className = 'datatable-loading-content'; + + // Add close button if dismissable + if (this.options.allowDismiss) { + const closeBtn = document.createElement('button'); + closeBtn.className = 'datatable-loading-close'; + closeBtn.innerHTML = '×'; + closeBtn.setAttribute('aria-label', 'Close loading overlay'); + closeBtn.setAttribute('title', 'Close'); + closeBtn.setAttribute('type', 'button'); + closeBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.hideFull(); + }; + content.appendChild(closeBtn); + } + + // Create spinner + const spinner = document.createElement('div'); + spinner.className = 'datatable-loading-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + content.appendChild(spinner); + + // Add text + const text = document.createElement('div'); + text.className = 'datatable-loading-text'; + text.textContent = this.options.loadingText; + content.appendChild(text); + + // Add subtext if provided + if (this.options.subtext) { + const subtext = document.createElement('div'); + subtext.className = 'datatable-loading-subtext'; + subtext.textContent = this.options.subtext; + content.appendChild(subtext); + } + + // Add timeout message (initially hidden) + const timeoutMsg = document.createElement('div'); + timeoutMsg.className = 'datatable-loading-timeout'; + timeoutMsg.textContent = this.options.timeoutMessage; + content.appendChild(timeoutMsg); + + // Add progress bar (initially hidden) + const progressContainer = document.createElement('div'); + progressContainer.className = 'datatable-loading-progress'; + const progressBar = document.createElement('div'); + progressBar.className = 'datatable-loading-progress-bar'; + progressContainer.appendChild(progressBar); + content.appendChild(progressContainer); + + overlay.appendChild(content); + return overlay; + } + + /** + * Create the mini indicator DOM structure + */ + createMiniIndicator() { + const mini = document.createElement('div'); + mini.id = 'datatable-loading-mini'; + mini.className = 'datatable-loading-mini'; + mini.setAttribute('role', 'status'); + mini.setAttribute('aria-live', 'polite'); + + const spinner = document.createElement('div'); + spinner.className = 'datatable-loading-mini-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + mini.appendChild(spinner); + + const text = document.createElement('div'); + text.className = 'datatable-loading-mini-text'; + text.textContent = this.options.miniLoadingText; + mini.appendChild(text); + + return mini; + } + + /** + * Show the loading overlay + */ + show() { + this.isLoading = true; + + if (this.overlay && !this.isVisible) { + // Reset timeout message + const timeoutMsg = this.overlay.querySelector('.datatable-loading-timeout'); + const progressBar = this.overlay.querySelector('.datatable-loading-progress'); + if (timeoutMsg) timeoutMsg.classList.remove('show'); + if (progressBar) progressBar.classList.remove('show'); + + // Use requestAnimationFrame for smoother rendering + requestAnimationFrame(() => { + this.overlay.classList.add('active'); + this.isVisible = true; + document.body.style.overflow = 'hidden'; // Prevent scrolling + }); + + // Set timeout to show warning message + if (this.options.showTimeoutAfter > 0) { + this.timeoutTimer = setTimeout(() => { + if (this.isVisible) { + if (timeoutMsg) timeoutMsg.classList.add('show'); + if (progressBar) progressBar.classList.add('show'); + } + }, this.options.showTimeoutAfter); + } + } + } + + /** + * Hide only the full overlay (show mini indicator instead) + */ + hideFull() { + if (this.overlay && this.isVisible) { + // Clear timeout timer + if (this.timeoutTimer) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + + this.overlay.classList.remove('active'); + this.isVisible = false; + document.body.style.overflow = ''; // Restore scrolling + + // Show mini indicator if still loading + if (this.isLoading && this.miniIndicator) { + this.miniIndicator.classList.add('active'); + } + } + } + + /** + * Hide the loading overlay (and mini indicator) + */ + hide() { + this.isLoading = false; + + if (this.overlay && this.isVisible) { + // Clear timeout timer + if (this.timeoutTimer) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + + this.overlay.classList.remove('active'); + this.isVisible = false; + document.body.style.overflow = ''; // Restore scrolling + } + + // Hide mini indicator + if (this.miniIndicator) { + this.miniIndicator.classList.remove('active'); + } + } + + /** + * Update the loading text dynamically + */ + updateText(newText, newSubtext = null) { + const textElement = this.overlay.querySelector('.datatable-loading-text'); + if (textElement) { + textElement.textContent = newText; + } + + if (newSubtext !== null) { + const subtextElement = this.overlay.querySelector('.datatable-loading-subtext'); + if (subtextElement) { + subtextElement.textContent = newSubtext; + } + } + } + + /** + * Destroy the overlay and remove from DOM + */ + destroy() { + if (this.overlay) { + this.hide(); + this.overlay.remove(); + this.overlay = null; + } + if (this.miniIndicator) { + this.miniIndicator.remove(); + this.miniIndicator = null; + } + } +} + +/** + * Factory function to create and attach loading overlay to a DataTable + * @param {DataTable} table - The DataTables instance + * @param {Object} options - Configuration options + * @returns {DataTableLoadingOverlay} The overlay instance + */ +function attachLoadingOverlay(table, options = {}) { + const overlay = new DataTableLoadingOverlay(options); + + // Hook into DataTables events + table.on('processing.dt', function(e, settings, processing) { + if (processing) { + overlay.show(); + } else { + overlay.hide(); + } + }); + + // Ensure overlay is hidden on table initialization complete + table.on('init.dt', function() { + overlay.hide(); + }); + + // Handle errors - hide overlay if AJAX fails + table.on('error.dt', function() { + overlay.hide(); + }); + + return overlay; +} + +/** + * Convenience function to create overlay with default settings + */ +function createDataTableLoadingOverlay(isFrench = false, spinnerType = 'spinner') { + return new DataTableLoadingOverlay({ + loadingText: isFrench ? 'Chargement des données...' : 'Loading data...', + subtext: isFrench ? 'Veuillez patienter pendant que nous filtrons vos résultats' : 'Please wait while we filter your results', + timeoutMessage: isFrench + ? 'Cela prend plus de temps que prévu. Vous pouvez fermer cette fenêtre si nécessaire.' + : 'This is taking longer than expected. You can close this dialog if needed.', + miniLoadingText: isFrench ? 'Chargement...' : 'Loading...', + spinnerType: spinnerType, + showTimeoutAfter: 8000, // Show message after 8 seconds + allowDismiss: true + }); +} + +// Export for use in other scripts +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + DataTableLoadingOverlay, + attachLoadingOverlay, + createDataTableLoadingOverlay + }; +} diff --git a/src/main/resources/static/js/pageFeedback-dashboard.js b/src/main/resources/static/js/pageFeedback-dashboard.js new file mode 100644 index 00000000..90993587 --- /dev/null +++ b/src/main/resources/static/js/pageFeedback-dashboard.js @@ -0,0 +1,506 @@ +$(document).ready(function () { + // Function to parse the query string and get the value of a specific parameter + function getQueryParam(param) { + var searchParams = new URLSearchParams(window.location.search); + return searchParams.get(param); + } + + // Utility function to format numbers with comma separators + function formatNumberWithCommas(number) { + if (number == null || number === '') return number; + return parseInt(number).toLocaleString(); + } + + // Check if the 'lang' query parameter is set to 'fr' + var isFrench = langSession === "fr"; + var now = new Date(); + var formattedDate = now.getMonth() + 1 + "/" + now.getDate() + "/" + now.getFullYear(); + var formattedEarliestDate = moment(earliestDate).format("YYYY/MM/DD"); + var formattedLatestDate = moment(latestDate).format("YYYY/MM/DD"); + // Utility functions + function debounce(func, delay) { + let debounceTimer; + return function () { + const context = this; + const args = arguments; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => func.apply(context, args), delay); + }; + } + function newexportaction(e, dt, button, config) { + var self = this; + var oldStart = dt.settings()[0]._iDisplayStart; + dt.one("preXhr", function (e, s, data) { + // Just this once, load all data from the server... + data.start = 0; + data.length = 2147483647; + dt.one("preDraw", function (e, settings) { + if (button[0].className.indexOf("buttons-excel") >= 0) { + $.fn.dataTable.ext.buttons.excelHtml5.available(dt, config) ? $.fn.dataTable.ext.buttons.excelHtml5.action.call(self, e, dt, button, config) : $.fn.dataTable.ext.buttons.excelFlash.action.call(self, e, dt, button, config); + } else if (button[0].className.indexOf("buttons-csv") >= 0) { + $.fn.dataTable.ext.buttons.csvHtml5.available(dt, config) ? $.fn.dataTable.ext.buttons.csvHtml5.action.call(self, e, dt, button, config) : $.fn.dataTable.ext.buttons.csvFlash.action.call(self, e, dt, button, config); + } + dt.one("preXhr", function (e, s, data) { + // DataTables thinks the first item displayed is index 0, but we're not drawing that. + // Set the property to what it was before exporting. + settings._iDisplayStart = oldStart; + data.start = oldStart; + }); // Reload the grid with the original page. Otherwise, API functions like table.cell(this) don't work properly. + setTimeout(dt.ajax.reload, 0); // Prevent rendering of the full data to the DOM + return false; + }); + }); // Requery the server with the new one-time export settings + dt.ajax.reload(); + } + + function resetFilters() { + // Reset select elements to their default option (usually the first one) + $("#department").val(""); + $("#language").val(""); + $("#errorComments").prop("checked", false); + $("#theme").val(""); + $("#section").val(""); + // Clear text input fields + $("#url").val(""); + $("#comments").val(""); + + // Reset the Date Range Picker to the initial dates + // Format the earliest and latest dates in YYYY/MM/DD format + + // Reset the Date Range Picker to the initial dates with formatted strings + $("#dateRangePicker").data("daterangepicker").setStartDate(formattedEarliestDate); + $("#dateRangePicker").data("daterangepicker").setEndDate(formattedLatestDate); + + // Update the display with formatted dates + $("#dateRangePicker").val(formattedEarliestDate + " - " + formattedLatestDate); + + // Reload the DataTable to reflect the reset filters + table.ajax.reload(); +} + + function getLastFiscalQuarter() { + let today = moment(); + let fiscalYearStart = moment().month() < 3 ? moment().subtract(1, "year").month(3).startOf("month") : moment().month(3).startOf("month"); // Adjust based on fiscal year starting in April + let quarterStart, quarterEnd; + + // Determine the current fiscal quarter + if (today.isBetween(fiscalYearStart, fiscalYearStart.clone().add(2, "months").endOf("month"))) { + // Last quarter is Q4 of the previous fiscal year + quarterStart = fiscalYearStart.clone().subtract(1, "year").add(9, "months"); + quarterEnd = fiscalYearStart.clone().subtract(1, "day"); + } else if (today.isBefore(fiscalYearStart.clone().add(6, "months"))) { + // Last quarter is Q1 + quarterStart = fiscalYearStart; + quarterEnd = fiscalYearStart.clone().add(2, "months").endOf("month"); + } else if (today.isBefore(fiscalYearStart.clone().add(9, "months"))) { + // Last quarter is Q2 + quarterStart = fiscalYearStart.clone().add(3, "months"); + quarterEnd = fiscalYearStart.clone().add(5, "months").endOf("month"); + } else { + // Last quarter is Q3 + quarterStart = fiscalYearStart.clone().add(6, "months"); + quarterEnd = fiscalYearStart.clone().add(8, "months").endOf("month"); + } + + return [quarterStart, quarterEnd]; + } + + // Initialize loading overlay + var loadingOverlay = createDataTableLoadingOverlay(isFrench, 'spinner'); + + // Show loading overlay immediately for initial table load + loadingOverlay.show(); + + // DataTable initialization + var table = new DataTable("#myTable", { + language: isFrench ? { url: "//cdn.datatables.net/plug-ins/2.3.2/i18n/fr-FR.json" } : undefined, + stripeClasses: [], + bSortClasses: false, + order: [[0, "desc"]], + processing: true, + serverSide: true, + retrieve: true, + lengthMenu: [ + [10, 25, 50, 100], + [10, 25, 50, 100], + ], + pageLength: 50, + orderCellsTop: true, + fixedHeader: true, + responsive: true, + drawCallback: function () { + fetchTotalCommentsCount(); + fetchTotalPagesCount(); + fetchDataAndCreateChart(); + }, + dom: 'Br<"table-responsive"t>tilp', + ajax: { + url: "/dashboardData", + type: "GET", + dataSrc: function(json) { + return json.data; + }, + data: function (d) { + d.language = $("#language").val(); + d.department = $("#department").val(); + + var commentsVal = $("#comments").val(); + if (commentsVal && commentsVal.trim() !== "") { + d.comments = commentsVal.trim(); + } else { + delete d.comments; // Remove the filter from request + } + d.section = $("#section").val(); + d.theme = $("#theme").val(); + d.url = $("#url").val(); + if ($("#errorComments").prop("checked")) { + d.error_keyword = "true"; // Only send if checked + } + + var dateRangePickerValue = $("#dateRangePicker").val(); + if (dateRangePickerValue) { + var dateRange = $("#dateRangePicker").data("daterangepicker"); + d.startDate = dateRange.startDate.format("YYYY-MM-DD"); + d.endDate = dateRange.endDate.format("YYYY-MM-DD"); + } else { + // If the date range picker is empty, do not send startDate and endDate in the request + delete d.startDate; // Ensure startDate is not included in the AJAX request + delete d.endDate; // Ensure endDate is not included in the AJAX request + } + + }, + error: function (xhr, error, thrown) { + alert(isFrench ? "Erreur lors de la récupération des données. Veuillez rafraîchir la page et réessayer." : "Error retrieving data. Please refresh the page and try again."); + console.log("xhr: " + xhr); + console.log("error: " + error); + console.log("thrown : " + thrown); + }, + }, + buttons: [ + { + extend: "csvHtml5", + className: "btn btn-default", + exportOptions: { + columns: [2, 1, 3, 0, 4, 5], // This will export only visible columns + modifier: { + page: "all", // This tells DataTables to export data from all pages, not just the current page + }, + }, + action: newexportaction, + filename: (isFrench ? "Outil_de_retroaction-" : "Page_feedback-") + new Date().getFullYear() + "-" + ("0" + (new Date().getMonth() + 1)).slice(-2) + "-" + ("0" + new Date().getDate()).slice(-2), + }, + { + extend: "excelHtml5", + className: "btn btn-default", + exportOptions: { + columns: [2, 1, 3, 0, 4, 5], // This will export only visible columns + modifier: { + page: "all", // This tells DataTables to export data from all pages, not just the current page + }, + }, + action: newexportaction, + filename: (isFrench ? "Outil_de_retroaction-" : "Page_feedback-") + new Date().getFullYear() + "-" + ("0" + (new Date().getMonth() + 1)).slice(-2) + "-" + ("0" + new Date().getDate()).slice(-2), + }, + ], + columns: [ + { data: "institution" }, // Dept (visible in table) + { + data: "url", + render: function (data, type, row) { + // Wrap any content of the 'url' column with an anchor tag + return '' + data + ""; + }, + }, + { + data: "urlEntries", + render: function (data, type, row) { + // Format numbers with comma separators for display + if (type === 'display' || type === 'type') { + return formatNumberWithCommas(data); + } + return data; + } + }, + { data: "language", visible: false }, // Language (hidden in table, but in CSV) + { data: "section", visible: false }, // Section (hidden in table, but in CSV) + { data: "theme", visible: false }, // Theme (hidden in table, but in CSV) + ], + }); + + // Attach loading overlay to DataTable events + attachLoadingOverlay(table, { + loadingText: isFrench ? 'Chargement des données...' : 'Loading data...', + subtext: isFrench ? 'Veuillez patienter pendant que nous filtrons vos résultats' : 'Please wait while we filter your results', + spinnerType: 'spinner' + }); + + // Hide loading overlay after initial table draw + table.on('draw.dt', function() { + loadingOverlay.hide(); + }); + + function fetchTotalCommentsCount() { + fetch("/pageFeedback/totalCommentsCount") + .then((response) => response.text()) + .then((totalCommentsCount) => { + // Update the total comments count in the element with comma formatting + $(".stat .totalCommentCount").text(formatNumberWithCommas(totalCommentsCount)); + }) + .catch((err) => { + console.warn("Something went wrong.", err); + }); + } + + function fetchTotalPagesCount() { + fetch("/pageFeedback/totalPagesCount") + .then((response) => response.text()) + .then((totalPagesCount) => { + // Update the total pages count in the element with comma formatting + $(".stat .totalPagesCount").text(formatNumberWithCommas(totalPagesCount)); + }) + .catch((err) => { + console.warn("Something went wrong.", err); + }); + } + + $(".reset-filters").on("click", function () { + resetFilters(); + }); + + $("#dateRangePicker").daterangepicker( + { + opens: "left", + startDate: moment(earliestDate), + endDate: moment(latestDate), + minDate: moment(earliestDate), // Set the earliest selectable date + maxDate: moment(latestDate), + alwaysShowCalendars: true, + locale: { + format: "YYYY/MM/DD", + cancelLabel: isFrench ? "Effacer" : "Clear", + applyLabel: isFrench ? "Appliquer" : "Apply", + customRangeLabel: isFrench ? "Période spécifique" : "Custom Range", + firstDay: isFrench ? 1 : 0, // Start with Monday + daysOfWeek: isFrench ? ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"] : undefined, // Define days for French + monthNames: isFrench ? ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"] : undefined, // Define months for French + }, + ranges: { + [isFrench ? "Toutes les dates" : "All Dates"]: [moment(earliestDate), moment(latestDate)], + [isFrench ? "Hier" : "Yesterday"]: [moment().subtract(1, "days"), moment().subtract(1, "days")], + [isFrench ? "7 derniers jours" : "Last 7 Days"]: [moment().subtract(7, "days"), moment()], + [isFrench ? "30 derniers jours" : "Last 30 Days"]: [moment().subtract(30, "days"), moment()], + [isFrench ? "Ce mois-ci" : "This Month"]: [moment().startOf("month"), moment().endOf("month")], + [isFrench ? "Le mois dernier" : "Last Month"]: [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")], + [isFrench ? "Dernier trimestre" : "Last Quarter"]: getLastFiscalQuarter(), + }, + }, + function (start, end, label) { + $("#dateRangePicker").val(start.format("YYYY/MM/DD") + " - " + end.format("YYYY/MM/DD")); + // Reload the DataTable with the new date range from the input value. + table.ajax.reload(); + } + ); + + $("#dateRangePicker").on("cancel.daterangepicker", function (ev, picker) { + // Set the date range picker to the earliest and latest dates + picker.setStartDate(moment(earliestDate)); + picker.setEndDate(moment(latestDate)); + // Update the input field to show the earliest and latest dates + $("#dateRangePicker").val(formattedEarliestDate + " - " + formattedLatestDate); + // Reload DataTables to reflect the reset date range + table.ajax.reload(); + }); + + $("#downloadCSV").on("click", function () { + table.button(".buttons-csv").trigger(); + }); + + $("#downloadExcel").on("click", function () { + table.button(".buttons-excel").trigger(); + }); + + // $(document).on("click", "a[href*='design.canada.ca'], a[href*='conception.canada.ca']", function (e) { + // e.preventDefault(); // Prevent the default link behavior + // window.open($(this).attr("href"), "_blank"); // Open the link in a new tab/window + // }); + + tippy("#section-tool-tip", { + content: isFrench ? "Une valeur ajoutée manuellement à certaines pages" : "A value manually added to select pages", + }); + + tippy("#theme-tool-tip", { + content: isFrench ? "Thèmes de navigation de Canada.ca " : "Canada.ca navigation themes ", + }); + function calculateRollingAverage(data, windowSize) { + let rollingAverages = []; + for (let i = 0; i <= data.length - windowSize; i++) { + let windowData = data.slice(i, i + windowSize); + let windowSum = windowData.reduce((sum, value) => sum + value, 0); + let average = windowSum / windowSize; + rollingAverages.push(parseInt(average)); + } + return rollingAverages; +}function fetchDataAndCreateChart() { +//error keyword filter + const errorKeywordChecked = $("#errorComments").prop("checked"); + let url = "/chartData"; + let params = []; + + if (errorKeywordChecked) params.push("error_keyword=true"); + + // Date range + const dateRangePickerValue = $("#dateRangePicker").val(); + if (dateRangePickerValue) { + const dateRange = $("#dateRangePicker").data("daterangepicker"); + params.push("startDate=" + encodeURIComponent(dateRange.startDate.format("YYYY-MM-DD"))); + params.push("endDate=" + encodeURIComponent(dateRange.endDate.format("YYYY-MM-DD"))); + } + + // Other filters (need to turn this into a module that takes an ID as a parameter) + if ($("#language").val()) params.push("language=" + encodeURIComponent($("#language").val())); + if ($("#department").val()) params.push("department=" + encodeURIComponent($("#department").val())); + if ($("#comments").val()) params.push("comments=" + encodeURIComponent($("#comments").val())); + if ($("#section").val()) params.push("section=" + encodeURIComponent($("#section").val())); + if ($("#theme").val()) params.push("theme=" + encodeURIComponent($("#theme").val())); + if ($("#url").val()) params.push("url=" + encodeURIComponent($("#url").val())); + + if (params.length > 0) url += "?" + params.join("&"); + + // Fetch the data from your endpoint + fetch(url) + .then((response) => response.json()) + .then((data) => { + // Extract categories (dates) and comments data + const categories = data.map((item) => item.date); + const commentsData = data.map((item) => item.comments); + + // Calculate rolling average (e.g., over 7 days) + const windowSize = 7; // Adjust this value as needed + const rollingAverages = calculateRollingAverage(commentsData, windowSize); + + const paddedRollingAverages = new Array(windowSize - 1).fill(null).concat(rollingAverages); + + // Now create the chart with the data + Highcharts.chart("chart", { + chart: { + type: "column", + }, + title: { + text: isFrench ? "Commentaires par jour" : "Comments by day", + align: "left", + style: { + fontSize: "20px", // Adjust title font size here + }, + }, + xAxis: { + categories: categories, // Set the categories from the data + crosshair: true, + accessibility: { + description: "Dates", + }, + labels: { + style: { + fontSize: "14px", // Adjust X axis labels font size here + }, + }, + }, + yAxis: { + min: 0, + title: { + text: isFrench ? "Nombre de commentaires" : "Number of Comments", + style: { + fontSize: "16px", // Adjust Y axis title font size here + fontWeight: "bold", + }, + }, + labels: { + style: { + fontSize: "16px", // Adjust Y axis labels font size here + }, + formatter: function() { + return formatNumberWithCommas(this.value); + } + }, + }, + legend: { + style: { + fontSize: "16px", // Adjust legend font size here + }, + itemStyle: { + fontSize: "14px", // Adjust legend item font size here + }, + }, + tooltip: { + valueSuffix: isFrench ? " commentaires" : " comments", + style: { + fontSize: "16px", // Adjust font size for text in the tooltip on hover + }, + formatter: function() { + return '' + this.x + '
' + + this.series.name + ': ' + formatNumberWithCommas(this.y) + '' + + (isFrench ? " commentaires" : " comments"); + } + }, + plotOptions: { + column: { + pointPadding: 0, // Minimizes the space between points within the same category + groupPadding: 0.1, // Adjust space between categories + borderWidth: 0, + }, + }, + series: [ + { + name: isFrench ? "Commentaires" : "Comments", + data: commentsData, // Set the data from the data + }, + { + name: isFrench ? "Moyenne mobile (7 jours)" : "Rolling Average (7 days)", + data: paddedRollingAverages, // Use the rolling average data + type: "line", // Display as a line chart + color: "#5D3FD3", // Optional: Set a different color for the rolling average + }, + ], + }); + }) + .catch((error) => { + console.error("Error fetching data: ", error); + }); +} + + + var detailsElement = $("#filterDetails"); + var summaryElement = $("#filterSummary"); + + detailsElement.on("toggle", function () { + if (detailsElement.prop("open")) { + summaryElement.text(isFrench ? "Voir moins de filtres" : "See less filters"); + } else { + summaryElement.text(isFrench ? "Voir plus de filtres" : "See more filters"); + } + }); + + $("#language, #department, #section, #theme").on("change", function () { + table.ajax.reload(); + }); + + // Handle error comments checkbox + $("#errorComments").on("change", function () { + const $label = $(this).closest('label'); + if ($(this).is(':checked')) { + $label.addClass('active'); + } else { + $label.removeClass('active'); + } + table.ajax.reload(); + + }); + + $("#comments, #url").on( + "keyup", + debounce(function (e) { + table.ajax.reload(); // Reload the table without resetting pagination + }, 800) + ); + +}); + diff --git a/src/main/resources/static/js/pageFeedback.js b/src/main/resources/static/js/pageFeedback.js new file mode 100644 index 00000000..84ed12d4 --- /dev/null +++ b/src/main/resources/static/js/pageFeedback.js @@ -0,0 +1,390 @@ +$(document).ready(function () { + // Function to parse the query string and get the value of a specific parameter + function getQueryParam(param) { + var searchParams = new URLSearchParams(window.location.search); + return searchParams.get(param); + } + + // Check if the 'lang' query parameter is set to 'fr' + var isFrench = langSession === "fr"; + var now = new Date(); + var formattedDate = now.getMonth() + 1 + "/" + now.getDate() + "/" + now.getFullYear(); + + // Utility functions + function debounce(func, delay) { + let debounceTimer; + return function () { + const context = this; + const args = arguments; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => func.apply(context, args), delay); + }; + } + + function newexportaction(e, dt, button, config) { + var self = this; + var oldStart = dt.settings()[0]._iDisplayStart; + dt.one("preXhr", function (e, s, data) { + // Just this once, load all data from the server... + data.start = 0; + data.length = 2147483647; + dt.one("preDraw", function (e, settings) { + if (button[0].className.indexOf("buttons-excel") >= 0) { + $.fn.dataTable.ext.buttons.excelHtml5.available(dt, config) ? $.fn.dataTable.ext.buttons.excelHtml5.action.call(self, e, dt, button, config) : $.fn.dataTable.ext.buttons.excelFlash.action.call(self, e, dt, button, config); + } + dt.one("preXhr", function (e, s, data) { + // DataTables thinks the first item displayed is index 0, but we're not drawing that. + // Set the property to what it was before exporting. + settings._iDisplayStart = oldStart; + data.start = oldStart; + }); + setTimeout(dt.ajax.reload, 0); // Prevent rendering of the full data to the DOM + return false; + }); + }); + dt.ajax.reload(); + } + + function resetFilters() { + // Reset select elements to their default option (usually the first one) + $("#department").val(""); + $("#language").val(""); + $("#errorComments").prop("checked", false); + $("#theme").val(""); + $("#section").val(""); + // Clear text input fields + $("#url").val(""); + $("#comments").val(""); + pageSelect.setData([]); + pageSelect.setSelected([]); + $("#pages").val(""); + + // Reset the Date Range Picker to the initial dates + updateDateRangePicker(); + table.ajax.reload(); + } + + function getLastFiscalQuarter() { + let today = moment(); + let fiscalYearStart = moment().month() < 3 ? moment().subtract(1, "year").month(3).startOf("month") : moment().month(3).startOf("month"); + let quarterStart, quarterEnd; + + if (today.isBetween(fiscalYearStart, fiscalYearStart.clone().add(2, "months").endOf("month"))) { + quarterStart = fiscalYearStart.clone().subtract(1, "year").add(9, "months"); + quarterEnd = fiscalYearStart.clone().subtract(1, "day"); + } else if (today.isBefore(fiscalYearStart.clone().add(6, "months"))) { + quarterStart = fiscalYearStart; + quarterEnd = fiscalYearStart.clone().add(2, "months").endOf("month"); + } else if (today.isBefore(fiscalYearStart.clone().add(9, "months"))) { + quarterStart = fiscalYearStart.clone().add(3, "months"); + quarterEnd = fiscalYearStart.clone().add(5, "months").endOf("month"); + } else { + quarterStart = fiscalYearStart.clone().add(6, "months"); + quarterEnd = fiscalYearStart.clone().add(8, "months").endOf("month"); + } + + return [quarterStart, quarterEnd]; + } + + function initializeDateRangePicker() { + $("#dateRangePicker").daterangepicker( + { + opens: "left", + startDate: moment(earliestDate), + endDate: moment(latestDate), + minDate: moment(earliestDate), + maxDate: moment(latestDate), + alwaysShowCalendars: true, + locale: { + format: "YYYY/MM/DD", + cancelLabel: isFrench ? "Effacer" : "Clear", + applyLabel: isFrench ? "Appliquer" : "Apply", + customRangeLabel: isFrench ? "Période spécifique" : "Custom Range", + firstDay: isFrench ? 1 : 0, + daysOfWeek: isFrench ? ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"] : undefined, + monthNames: isFrench ? ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"] : undefined, + }, + ranges: { + [isFrench ? "Toutes les dates" : "All Dates"]: [moment(earliestDate), moment(latestDate)], + [isFrench ? "Aujourd'hui" : "Today"]: [moment(), moment()], + [isFrench ? "Hier" : "Yesterday"]: [moment().subtract(1, "days"), moment().subtract(1, "days")], + [isFrench ? "7 derniers jours" : "Last 7 Days"]: [moment().subtract(6, "days"), moment()], + [isFrench ? "30 derniers jours" : "Last 30 Days"]: [moment().subtract(29, "days"), moment()], + [isFrench ? "Ce mois-ci" : "This Month"]: [moment().startOf("month"), moment().endOf("month")], + [isFrench ? "Le mois dernier" : "Last Month"]: [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")], + [isFrench ? "Dernier trimestre" : "Last Quarter"]: getLastFiscalQuarter(), + }, + }, + function (start, end, label) { + $("#dateRangePicker").val(start.format("YYYY/MM/DD") + " - " + end.format("YYYY/MM/DD")); + table.ajax.reload(); + } + ); + + $("#dateRangePicker").on("cancel.daterangepicker", function (ev, picker) { + picker.setStartDate(moment(earliestDate)); + picker.setEndDate(moment(latestDate)); + $("#dateRangePicker").val(moment(earliestDate).format("YYYY/MM/DD") + " - " + moment(latestDate).format("YYYY/MM/DD")); + table.ajax.reload(); + }); + } + + function updateDateRangePicker() { + var dateRangePicker = $("#dateRangePicker").data("daterangepicker"); + dateRangePicker.setStartDate(moment(earliestDate)); + dateRangePicker.setEndDate(moment(latestDate)); + $("#dateRangePicker").val(moment(earliestDate).format("YYYY/MM/DD") + " - " + moment(latestDate).format("YYYY/MM/DD")); + } + + // Initialize loading overlay + var loadingOverlay = createDataTableLoadingOverlay(isFrench, 'spinner'); + + // Show loading overlay immediately for initial table load + loadingOverlay.show(); + + // DataTable initialization + var table = new DataTable("#myTable", { + language: isFrench ? { url: "//cdn.datatables.net/plug-ins/2.3.2/i18n/fr-FR.json" } : undefined, + stripeClasses: [], + bSortClasses: false, + order: [[0, "desc"]], + processing: true, + serverSide: true, + retrieve: true, + lengthMenu: [ + [10, 25, 50, 100], + [10, 25, 50, 100], + ], + pageLength: 50, //adds default comment count to 50 + orderCellsTop: true, + fixedHeader: true, + responsive: true, + dom: 'Br<"table-responsive"t>tilp', + ajax: { + url: "/feedbackData", + type: "GET", + data: function (d) { + d.titles = $("#pages").val(); + d.language = $("#language").val(); + d.department = $("#department").val(); + d.comments = $("#comments").val(); + d.section = $("#section").val(); + d.theme = $("#theme").val(); + d.url = $("#url").val(); + if ($("#errorComments").prop("checked")) { + d.error_keyword = "true"; // Only send if checked + } + + var dateRangePickerValue = $("#dateRangePicker").val(); + if (dateRangePickerValue) { + var dateRange = $("#dateRangePicker").data("daterangepicker"); + d.startDate = dateRange.startDate.format("YYYY-MM-DD"); + d.endDate = dateRange.endDate.format("YYYY-MM-DD"); + } else { + delete d.startDate; + delete d.endDate; + } + }, + error: function (xhr, error, thrown) { + alert(isFrench ? "Erreur lors de la récupération des données. Veuillez rafraîchir la page et réessayer." : "Error retrieving data. Please refresh the page and try again."); + console.log("xhr: " + xhr); + console.log("error: " + error); + console.log("thrown : " + thrown); + }, + }, + buttons: [ + { + extend: 'csvHtml5', + className: 'btn btn-default', + text: isFrench ? 'Télécharger CSV' : 'Download CSV', + action: function (e, dt, button, config) { + e.preventDefault(); + var url = new URL(window.location.origin + '/exportCSV'); + var params = getFilterParams(); + Object.keys(params).forEach(key => url.searchParams.append(key, params[key])); + window.location.href = url.toString(); + } + }, + { + extend: 'excelHtml5', + className: 'btn btn-default', + text: isFrench ? 'Télécharger Excel' : 'Download Excel', + action: function (e, dt, button, config) { + e.preventDefault(); + var url = new URL(window.location.origin + '/exportExcel'); + var params = getFilterParams(); + Object.keys(params).forEach(key => url.searchParams.append(key, params[key])); + window.location.href = url.toString(); + } + } + ], + columns: [ + { data: "problemDate", width: "6%" }, + { data: "problemDetails", width: "50%" }, + { data: "institution", width: "6%" }, + { data: "title", width: "14%" }, + { + data: "url", + width: "24%", + render: function (data, type, row) { + return '' + data + ""; + }, + }, + { data: "timeStamp", visible: false }, + { data: "language", visible: false }, + { data: "section", visible: false }, + { data: "theme", visible: false }, + { data: "deviceType", visible: false }, + { data: "browser", visible: false }, + ], + }); + + // Attach loading overlay to DataTable events + attachLoadingOverlay(table, { + loadingText: isFrench ? 'Chargement des données...' : 'Loading data...', + subtext: isFrench ? 'Veuillez patienter pendant que nous filtrons vos résultats' : 'Please wait while we filter your results', + spinnerType: 'spinner' + }); + + // Hide loading overlay after initial table draw + table.on('draw.dt', function() { + loadingOverlay.hide(); + }); + + // SlimSelect initialization + var pageSelect = new SlimSelect({ + select: "#pages", + settings: { + hideSelected: true, + keepOrder: true, + placeholderText: isFrench ? "Filtrer par titre de page complet ou partiel" : "Filter by full or partial page title", + searchText: isFrench ? "Aucun résultat trouvé" : "No results found", + searchPlaceholder: isFrench ? "Recherche" : "Search", + searchingText: isFrench ? "Recherche en cours..." : "Searching...", + closeOnSelect: false, + }, + events: { + search: (search, currentData) => { + return new Promise((resolve, reject) => { + clearTimeout(pageSelect.debounceTimer); + pageSelect.debounceTimer = setTimeout(() => { + if (search.length < 2) { + return reject(isFrench ? "La recherche doit comporter au moins 2 caractères" : "Search must be at least 2 characters"); + } + + fetch("/pageTitles?search=" + encodeURIComponent(search), { + method: "GET", + headers: { + Accept: "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(isFrench ? "La réponse du réseau n'était pas correcte" : "Network response was not ok"); + } + return response.json(); + }) + .then((data) => { + const options = data + .filter((title) => !currentData.some((optionData) => optionData.value === title)) + .map((title) => ({ text: title, value: title })); + + resolve(options); + }) + .catch((error) => { + console.error("Error fetching page titles:", error); + reject(error); + }); + }, 800); + }); + }, + }, + }); + + // Event bindings + $("#pages").on("change", function () { + table.ajax.reload(); + }); + + $(".reset-filters").on("click", resetFilters); + + initializeDateRangePicker(); + + $("#downloadCSV").on("click", function () { + var url = new URL(window.location.origin + '/exportCSV'); + var params = getFilterParams(); + Object.keys(params).forEach(key => url.searchParams.append(key, params[key])); + window.location.href = url.toString(); + }); + + $("#downloadExcel").on("click", function () { + var url = new URL(window.location.origin + '/exportExcel'); + var params = getFilterParams(); + Object.keys(params).forEach(key => url.searchParams.append(key, params[key])); + window.location.href = url.toString(); + }); + + tippy("#section-tool-tip", { + content: isFrench ? "Une valeur ajoutée manuellement à certaines pages" : "A value manually added to select pages", + }); + + tippy("#theme-tool-tip", { + content: isFrench ? "Thèmes de navigation de Canada.ca " : "Canada.ca navigation themes ", + }); + + var detailsElement = $("#filterDetails"); + var summaryElement = $("#filterSummary"); + + detailsElement.on("toggle", function () { + if (detailsElement.prop("open")) { + summaryElement.text(isFrench ? "Voir moins de filtres" : "See less filters"); + } else { + summaryElement.text(isFrench ? "Voir plus de filtres" : "See more filters"); + } + }); + + $("#language, #department, #section, #theme").on("change", function () { + table.ajax.reload(); + }); + + // Handle error comments checkbox + $("#errorComments").on("change", function () { + const $label = $(this).closest('label'); + if ($(this).is(':checked')) { + $label.addClass('active'); + } else { + $label.removeClass('active'); + } + table.ajax.reload(); + }); + + $("#comments, #url").on( + "keyup", + debounce(function (e) { + table.ajax.reload(); + }, 800) + ); + + // Add this new function to get filter parameters + function getFilterParams() { + var params = { + titles: $("#pages").val(), + language: $("#language").val(), + error_keyword: $("#errorComments").prop("checked"), + department: $("#department").val(), + comments: $("#comments").val(), + section: $("#section").val(), + theme: $("#theme").val(), + url: $("#url").val() + }; + + var dateRangePickerValue = $("#dateRangePicker").val(); + if (dateRangePickerValue) { + var dateRange = $("#dateRangePicker").data('daterangepicker'); + params.startDate = dateRange.startDate.format('YYYY-MM-DD'); + params.endDate = dateRange.endDate.format('YYYY-MM-DD'); + } + + return params; + } +}); diff --git a/src/main/resources/static/js/spring-friendly.min.js b/src/main/resources/static/js/spring-friendly.min.js new file mode 100644 index 00000000..21ce4550 --- /dev/null +++ b/src/main/resources/static/js/spring-friendly.min.js @@ -0,0 +1,33 @@ +!(function (e) { + function n(e, t, r, o) { + var u; + if (jQuery.isArray(t)) + jQuery.each(t, function (t, u) { + r || i.test(e) + ? o(e, u) + : n(e + "[" + ("object" == typeof u ? t : "") + "]", u, r, o); + }); + else if (r || "object" !== jQuery.type(t)) o(e, t); + else for (u in t) n(e + "." + u, t[u], r, o); + } + var t = /%20/g, + i = /\[\]$/; + e.param = function (e, i) { + var r, + o = [], + u = function (e, n) { + (n = jQuery.isFunction(n) ? n() : null == n ? "" : n), + (o[o.length] = encodeURIComponent(e) + "=" + encodeURIComponent(n)); + }; + if ( + (void 0 === i && + (i = jQuery.ajaxSettings && jQuery.ajaxSettings.traditional), + jQuery.isArray(e) || (e.jquery && !jQuery.isPlainObject(e))) + ) + jQuery.each(e, function () { + u(this.name, this.value); + }); + else for (r in e) n(r, e[r], i, u); + return o.join("&").replace(t, "+"); + }; +})(jQuery); diff --git a/src/main/resources/static/js/taskList.js b/src/main/resources/static/js/taskList.js new file mode 100644 index 00000000..a6df1a0b --- /dev/null +++ b/src/main/resources/static/js/taskList.js @@ -0,0 +1,414 @@ +export const taskList = [ + "COVID-19 cases, statistics", + "Vaccines and treatment", + "Get your Canadian COVID-19 proof of vaccination", + "Symptoms of COVID-19", + "Testing for COVID-19", + "Prevention, masks", + "Canada Recovery Caregiving Benefit (CRCB)", + "Canada Recovery Sickness Benefit (CRSB)", + "Canada Recovery Benefit (CRB)", + "Canada Worker Lockdown Benefit (CWLB)", + "Tourism and Hospitality Recovery Program (THRP)", + "Hardest-Hit Businesses Recovery Program (HHBRP)", + "Canada Emergency Wage Subsidy (CEWS)", + "Canada Emergency Rent Subsidy (CERS)", + "Canada Recovery Hiring Program (CRHP)", + "COVID-19 requirements for travellers to Canada", + "Travel inside Canada", + "Travel outside Canada", + "Other - Reason for my visit is not in this list", + "Jobs in the private sector", + "Jobs in the federal public service", + "Find a student job in the federal public service", + "Find an apprenticeship", + "Join the army", + "Apply to work and travel abroad with International Experience Canada (IEC) as a Canadian", + "Apply for a work permit", + "Apply for a Social Insurance Number (SIN)", + "Get job training", + "My Service Canada Account (MSCA) - View Records of Employment (ROE)", + "Post a job in the Job Bank", + "Hire a foreign worker", + "Apply for Guaranteed Income Supplement (GIS)", + "Old Age Seccurity (OAS) - Apply for Old Age Security", + "Old Age Seccurity (OAS) - View payment amounts", + "Canada Pension Plan (CPP) - Apply for Canada Pension Plan", + "Calculate your Retirement Payments", + "Find a pension payment date", + "Employment Insurance (EI) - Apply for Employment Insurance", + "Employment Insurance (EI) - Submit an Employment Insurance Report", + "My Service Canada Account (MSCA) - Sign in", + "My Service Canada Account (MSCA) - Register for an account", + "Sign up for direct deposit or update your banking information", + "Apply for an apprenticeship grant or loan", + "Apply for funding for Canada summer jobs", + "Support for Temporary Foreign Workers", + "Apply for Canadian citizenship", + "Check if you need a visa or electronic travel authorization (eTA) to travel to Canada", + "Check processing times", + "Check your application status", + "Contact Immigration, Refugees and Citizenship Canada", + "Find a designated learning institution", + "Find a visa application centre", + "Find an IRCC application package or form", + "Find out if you need to give your fingerprints and photo (biometrics) and where to do that", + "Find your National Occupational Classification (NOC)", + "Get, renew or replace a permanent resident card", + "How to extend your stay in Canada", + "Immigrate as a provincial nominee", + "Immigrate through Express Entry", + "IRCC secure account - register, sign in, help", + "Pay your fees online", + "Sponsor your family members to immigrate to Canada", + "Study in Canada – for non-Canadians", + "Use the Authorized Paid Representatives Portal", + "Apply for a copyright", + "Apply for a patent", + "Apply for a trademark", + "Apply for the Farm Debt Mediation Service", + "Consult a trade commissioner to receive international business information", + "Download RETScreen clean energy management software", + "Establish or expand your business in Canada as a foreign investor", + "File an annual corporate return", + "Find a bankruptcy or insolvency record", + "Find a corporation", + "Find a decision, notice, order, policy on radio, television or telecommunications", + "Find a patent", + "Find a trademark", + "Find Canadian importing companies", + "Find government grants, loans and financing", + "Trade negotiations and agreements", + "Incorporate a business", + "Look up industry statistics", + "Manage and report goods imported into Canada", + "Register a sole-proprietorship or partnership (not incorporated)", + "Review customs tariffs for importing goods to Canada", + "Set up a not-for-profit corporation", + "Apply for a study permit", + "ArriveCAN - Submit travel information or daily symptoms", + "Boarding flights to and within Canada", + "Check provincial and territorial restrictions", + "Check the Canada-United States border wait times – from Canada", + "Check the United States-Canada border wait times – from the United States", + "Check vaccination requirements to enter Canada", + "Compassionate travel quarantine exemptions (caring for others, funerals and support)", + "Contact a federal office – outside Canada (embassy, consulate, trade office)", + "Contact the Canada Border Services Agency (CBSA)", + "COVID-19 testing requirements for travellers", + "Publications and guides", + "Find out about identification requirements for crossing the Canadian border", + "Find out if you can enter Canada", + "Find out what you can bring on a plane", + "Follow mandatory quarantine or isolation", + "Identify what you can bring home to Canada", + "Latest news – Travel and emergency assistance", + "Apply for a new Canadian passport", + "Renew a Canadian passport", + "Register as a Canadian living or travelling outside Canada", + "Visit a national park or historic site", + "Travel advice and advisories", + "Travel health notices in effect for a country", + "Apply for a visa when travelling outside Canada", + "Apply for a visitor visa to Canada", + "Apply for a benefit for Indigenous people", + "My Service Canada Account (MSCA) - Check your Employment Insurance (EI) claim status and correspondence", + "My Service Canada Account (MSCA) - View payment information", + "My Service Canada Account (MSCA) - Check your CPP/OAS application status", + "My Service Canada Account (MSCA) - Check your estimated monthly Canada Pension Plan (CPP) benefits", + "My Service Canada Account (MSCA) - Get your T4E, T4A or NR4 tax slips", + "My Service Canada Account (MSCA) - View/Change your personal information", + "Employment Insurance (EI) - Check eligibility", + "Canada Pension Plan (CPP) - Check status of application", + "Canada Pension Plan (CPP) - Estimate monthly benefits", + "Canada Pension Plan (CPP) - Get statement of benefits", + "Canada Pension Plan (CPP) - Check payment information", + "Canada Pension Plan Disability (CPPD) - Apply for Canada Pension Plan Disability benefits", + "Canada Pension Plan (CPP) - Apply for a Survivors Pension", + "Apply for a student grant or loan", + "Get student loan repayment assistance", + "Delay repayment of a student loan (still studying)", + "Learn about benefits for people with disabilities", + "Apply for the Canada Child Benefit (CCB)", + "Look up expected child support payments", + "Sign into or create a Veterans Affairs Canada (VAC) account", + "Consult Canada's Food Guide", + "Contact Health Canada or the Public Health Agency of Canada", + "Find information on family violence and how to get help", + "Find information on producing and selling cannabis", + "Find out if a medical device is licenced for sale in Canada", + "Find out the hazardous properties of a human pathogen and how to work with it in a laboratory setting", + "Find out the safe cooking temperature for food", + "Find out the symptoms or risks for a disease or condition", + "Find out whether a drug is approved for use and sale in Canada", + "Find out whether a natural health product is licensed for use in Canada", + "Find out who is eligible for and how to access medical assistance in dying", + "Get consumer or health product recalls and safety alerts", + "Get food recalls and safety alerts", + "Get guidelines for immunization and vaccination practices", + "Get information on vaping or the risk of vaping", + "Learn about Canada's health care system", + "Learn about cannabis, its health effects, its medical use, its legalization in Canada", + "Learn about food labels", + "Learn about hazardous materials in the workplace", + "Learn about mental health support and how to get help", + "Learn about outbreaks of food and water-borne illnesses and diseases in Canada", + "Learn about the problematic use of drugs (such as opioids) and how to get help", + "Look up the key social determinants of health in Canada", + "Report a side effect to a drug, medical device, vaccine, or natural health product", + "Apply for the disability tax credit", + "Calculate payroll deductions", + "Change my address with the Canada Revenue Agency", + "Check online mail", + "Check the balance owing on a corporate income tax account", + "File a GST/HST return", + "File an employer information return, such as a T4 Summary", + "Find a Canada Revenue Agency telephone number", + "Find my Registered Retirement Savings Plan (RRSP) contribution limit", + "Find my Tax-free savings Account (TFSA) contribution limit", + "Find out how much my benefit payment will be", + "Find out when I can expect my tax refund", + "Find out which tax deductions, credits, or expenses I can claim", + "Find tax software", + "Find the income tax rates", + "Find the next benefit payment date", + "Get a copy of a notice of assessment or reassessment", + "Get a GST/HST access code", + "Get an income tax form and guide", + "Get the TD1 Personal Tax Credits Return form", + "Look up a charitable organization", + "Make a payment to the Canada Revenue Agency", + "Open a GST/HST account", + "Access My Account", + "Open a My Account", + "Access My Business Account", + "Open a My Business Account", + "Report suspected fraud", + "See my T4 information", + "Calculate the magnetic declination for a specified location and date", + "Check past weather conditions", + "Consult Canada's plan to fight climate change", + "Consult the reporting and inventory of greenhouse gas emissions", + "Current and past earthquakes in Canada", + "Current and past wildfires in Canada", + "Find a fuel efficient vehicle", + "Find out about forest conservation", + "Find out about the major pollutants and toxic substances affecting the environment", + "Find out how Canada is managing pollution", + "Find out if you need a fishing licence", + "Find out what Canada is doing about climate change", + "Find out which energy efficiency incentives and grants are available", + "Get an emergency kit (to be prepared during a natural disaster, power outage, bomb threat, etc)", + "Get and understand Air Quality Health Index ratings for my location", + "Get current and forecasted ice conditions", + "Get current and forecasted weather for your location", + "Get marine conditions (tides and ocean water levels)", + "Get satellite and air imagery", + "Get space weather forecasts", + "How to prepare for seasonal severe weather conditions", + "Learn about climate change", + "Learn about managing and reducing waste", + "Look up a species at risk in Canada", + "Make a home energy efficient", + "Renewable energy in Canada", + "Understand how pricing pollution works in Canada", + "Use a geodetic reference tool", + "Water level and flood monitoring in Canada (inland)", + "Access cyber security bulletins", + "Apply for a job in the military", + "Civilian jobs with National Defence", + "Find information on a specific unit, squadron or ship", + "Find out the various ranks in the military", + "Find out where the military is currently deployed on operations around the world", + "Join the Cadets", + "Learn about Canada's defence policy", + "Learn about cyber security threats", + "Learn about Passenger Protect program", + "Learn about the Canadian Army Reserve", + "Learn about the current list of terrorist entities", + "Learn about the required documents needed for air travel", + "Pay rates for military members", + "Read the Defence administrative orders and directives (DAOD)", + "Read the Queen's regulations and orders (QR&O)", + "Search for jobs in the military", + "Apply for a tax credit — film or video (CAVCO)", + "Apply to a grant or funding program — arts", + "Apply to a grant or funding program — cultural preservation", + "Apply to a grant or funding program — multiculturalism", + "Apply to a grant or funding program — music", + "Apply to a grant or funding program — sports", + "Participate in Winterlude events and activities", + "Participate in Canada Day celebrations", + "Participate in National Indigenous Peoples Day", + "Participate in Remembrance Day", + "Find information on human rights in Canada", + "Learn about Aboriginal history in Canada", + "Learn about the First World War", + "Look up soldiers who lost their lives in the military or RCMP", + "Learn about the Governor General or Lieutenant Governors and the appointment process", + "Download the lyrics and recordings of Canada’s national anthem", + "Learn about the official symbols of Canada", + "Discover the origins of the names of Canada's provincial and territorial capitals", + "Research your genealogy and family history", + "Search births, marriages and deaths recorded in Canada", + "View current census data", + "Find student job and internship programs", + "Find a decision, notice, order or policy about radio, television or telecommunications", + "Apply for a criminal record suspension", + "Apply for a cannabis record suspension", + "Get a criminal record check", + "Apply for or renew your firearms licence", + "Check the status of your firearms licence application", + "Change your address for your firearms licence", + "Contact a chief firearms officer", + "Learn about firearms safety, storage and when you need a licence", + "Join the RCMP", + "Find your local RCMP detachment (police service)", + "Check the National Sex Offender Registry", + "Check the Royal Canadian Mounted Police (RCMP) wanted list", + "Consult a law, treaty or regulation", + "Learn about how the Government of Canada responds to emergency events", + "Learn about national search and rescue", + "Look up a Canadian disaster", + "Prepare an emergency plan", + "Apply for the Memorial Grant Program for First Responders", + "Apply for your transportation security clearance", + "Assessing medical fitness of aviation personnel", + "Building, maintaining and registering an aircraft", + "Find out how to import or bring a vehicle into Canada", + "Flying an aircraft", + "Find where you can fly your drone", + "Learn about advanced vehicle technologies", + "Learn about child car seat safety", + "Find if there is a defect or recall for your vehicle, tires or child car seat", + "Learn about grade crossing safety", + "Learn about safety awareness when transporting dangerous goods", + "Learn about shipping or mailing lithium batteries", + "Learn about the containers required to transport dangerous goods by rail", + "Learn how to build an emergency response assistance plan in case of an accident while shipping dangerous goods", + "Learn how to get your transportation security clearance", + "Learn how to register your vessel", + "Learn tips on safe boating", + "Licensing and training of aviation personnel", + "Operating a commercial air service", + "Operating an airport or aerodrome", + "Read the Canadian Aviation Regulations (CARs)", + "Read the regulations for transporting dangerous goods", + "Read the rules for operating a federal railway", + "Find statistics for vehicle collisions", + "Training and certification of individuals", + "Authenticate documents so that they can be recognized outside Canada", + "Canada's feminist international assistance policy", + "Canada’s actions in a specific country or region", + "Canada’s foreign policy and international relations", + "Canada’s response to conflicts, crisis and disasters", + "Contact Global Affairs Canada – offices in Canada", + "Country insights (cultural information)", + "Democracy and good governance", + "Environment and climate change", + "Find international study or research opportunities in Canada for non-Canadians", + "Gender equality and the empowerment of women and girls", + "Human rights and dignity", + "International assistance budget (humanitarian aid and development projects)", + "International assistance projects funded by Canada", + "International treaties signed by Canada", + "Jobs with Global Affairs Canada (embassies/consulate, trade offices and partnership organizations)", + "Latest news – Foreign affairs (international relations and global issues)", + "Latest news – International development (humanitarian assistance and development projects)", + "Peace and security", + "Right to education (international development)", + "Sanctions imposed by Canada against specific countries, organisations and individuals", + "Scholarships to study in Canada – for non-Canadians", + "Scholarships to study outside Canada – for Canadians", + "Seek funding opportunities for international aid projects", + "Sustainable agriculture, green technologies and renewable energy", + "The right to health and nutrition", + "United nations (UN)", + "Volunteer for work in a humanitarian aid project", + "Buying a home", + "Calculate if you qualify for a mortgage", + "Calculate your credit card payments", + "Calculate your mortgage payments and/or prepayments", + "Cashing a Government of Canada cheque", + "Check Canada's tax treaties", + "Compare Canadian bank accounts", + "Consult the Federal Budget", + "Create a plan to get out of debt and/or savings goals", + "Determine current market prices for lumber, crude oil and other fuels", + "Find available disability benefits", + "Find information on loans and lines of credit", + "Find out about student lines of credit", + "Getting a home equity line of credit", + "How much you need for a down payment", + "Improving your credit score", + "Make a budget", + "Make a will and plan your estate", + "Making a complaint to your financial institution", + "Opening a bank account", + "Ordering your credit report and score", + "Understand severance pay", + "Apply for a grant for technological innovation - Industrial Research Assistance Program (IRAP)", + "Find a scientific, technical or medical journal", + "View the northern lights camera", + "Solar eclipses times and appreciation guide", + "Build your own projector to watch solar eclipses safely", + "Read biographies of Canadian astronauts", + "Official time across Canada", + "Check sunrise and sunset times", + "Get the latest National building code for Canada", + "Buy building codes or Certified Reference Materials", + "Consult the Canadian National Master Construction Specifications", + "View statistical data", + "Apply for an International Standard Book Number (ISBN) / catalogue number", + "Consult the annual report on advertising activities", + "Consult the Clerk’s Annual Report to the Prime Minister", + "Consult the Speech from the Throne", + "Examine the activities of the Independent Advisory Board for Senate Appointments", + "Find a government department or agency", + "Read a report or a publication", + "Find a government policy, directive, standard or guideline", + "Find public opinion research requirements", + "Find travel and hospitality expenses of government officials", + "Learn about Canada’s democratic institutions", + "Learn about Governor in Council appointments process", + "Learn about health and safety in federal properties", + "Federal government property management, maintenance, and construction", + "Learn about the Clerk of the Privy Council", + "Learn about the relationship between provinces and territories", + "Learn how the government provides financial support to provinces and territories", + "Make an access to information or personal information request", + "Review of the state of bilingualism in the federal Public Service", + "Review the progress of Government of Canada commitments", + "Search government grants and contributions", + "Search the Orders in Council database", + "See how the Government of Canada supports the LGBTQ2+ community", + "View public notices in the Canada Gazette", + "View Parliament Hill camera", + "Calculate my expected pension as a public servant", + "Contact the Government of Canada Pension Centre", + "Find a benefits form", + "Find available benefits for survivors of deceased public servants", + "Find guidance on leave requests due to COVID-19", + "Find guidance on working remotely during COVID-19", + "Find out what is considered harassment in the public service", + "Find out when I could retire as a public servant", + "Find public service employees rates of pay", + "Find public service executives rates of pay", + "Find public service staffing tools", + "Find public service students rates of pay", + "Help with pay issues (pay action requests, pay enquiry)", + "Get updates on public service collective bargaining", + "Get updates on the status of the Phoenix pay system", + "Learn about employee rights and responsibilities related to COVID-19", + "Learn about qualifications, competencies and classifications for public service jobs", + "Learn about temporary changes to benefits due to COVID-19", + "Learn about the public service pension plan", + "Learn about your public service pay", + "Make a public service dental insurance claim", + "Make a public service health care insurance claim", + "Read a public service collective agreement", + "Read about the performance management program for employees", + "See what's covered by dental insurance in the public service", + "See what's covered by health insurance in the public service", + "Access your pay (MyGCPay, CWA, Phoenix)", +]; diff --git a/src/main/resources/static/js/topTaskSurvey.js b/src/main/resources/static/js/topTaskSurvey.js new file mode 100644 index 00000000..c3d7b4e9 --- /dev/null +++ b/src/main/resources/static/js/topTaskSurvey.js @@ -0,0 +1,524 @@ +$(document).ready(function () { + // Constants and Configuration + const CONFIG = { + DEBOUNCE_DELAY: 800, + SEARCH_MIN_CHARS: 2, + SPINNER_HIDE_DELAY: 1000, + DATE_FORMAT: 'YYYY/MM/DD', + BACKEND_DATE_FORMAT: 'YYYY-MM-DD' + }; + + const ENDPOINTS = { + TOP_TASK_DATA: '/topTaskData', + TOTAL_DISTINCT_TASKS: '/topTask/totalDistinctTasks', + TOTAL_TASK_COUNT: '/topTask/totalTaskCount', + DEPARTMENTS: '/topTaskSurvey/departments', + TASK_NAMES: '/taskNames', + EXPORT_CSV: '/exportTopTaskCSV', + EXPORT_EXCEL: '/exportTopTaskExcel' + }; + + const MESSAGES = { + fr: { + ERROR_RETRIEVING_DATA: "Erreur lors de la récupération des données. Veuillez rafraîchir la page et réessayer.", + NO_DATA_EXPORT: "Aucune donnée à exporter avec les filtres sélectionnés.", + ERROR_CSV_DOWNLOAD: "Erreur lors du téléchargement du fichier CSV. Veuillez réessayer.", + ERROR_EXCEL_DOWNLOAD: "Erreur lors du téléchargement du fichier Excel. Veuillez réessayer.", + SEARCH_MIN_CHARS: "La recherche doit comporter au moins 2 caractères", + NETWORK_ERROR: "La réponse du réseau n'était pas correcte" + }, + en: { + ERROR_RETRIEVING_DATA: "Error retrieving data. Please refresh the page and try again.", + NO_DATA_EXPORT: "No data to export with the selected filters.", + ERROR_CSV_DOWNLOAD: "Error downloading CSV file. Please try again.", + ERROR_EXCEL_DOWNLOAD: "Error downloading Excel file. Please try again.", + SEARCH_MIN_CHARS: "Search must be at least 2 characters", + NETWORK_ERROR: "Network response was not ok" + } + }; + + var isFrench = langSession === "fr"; + var now = new Date(); + var formattedDate = now.getMonth() + 1 + "/" + now.getDate() + "/" + now.getFullYear(); + var loadingSpinner = $(".loading-spinner"); + + // Initialize loading overlay + var loadingOverlay = createDataTableLoadingOverlay(isFrench, 'spinner'); + + // Show loading overlay immediately for initial table load + loadingOverlay.show(); + + // Utility functions + function formatNumberWithCommas(number) { + if (number == null || number === '') return number; + return parseInt(number).toLocaleString(); + } + + function getMessage(key) { + return MESSAGES[isFrench ? 'fr' : 'en'][key] || MESSAGES.en[key]; + } + + function showAlert(messageKey) { + alert(getMessage(messageKey)); + } + + function handleError(error, messageKey, context = '') { + console.error(`Error in ${context}:`, error); + showAlert(messageKey); + loadingSpinner.hide(); + } + + function debounce(func, delay) { + let debounceTimer; + return function () { + const context = this; + const args = arguments; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => func.apply(context, args), delay); + }; + } + + function createDownloadLink(blob, filename) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } + + function extractFilenameFromHeader(contentDisposition, defaultFilename) { + if (contentDisposition && contentDisposition.indexOf('attachment') !== -1) { + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(contentDisposition); + if (matches && matches[1]) { + return matches[1].replace(/['"]/g, ''); + } + } + return defaultFilename; + } + + function getLastFiscalQuarter() { + let today = moment(); + let fiscalYearStart = moment().month() < 3 ? moment().subtract(1, "year").month(3).startOf("month") : moment().month(3).startOf("month"); + let quarterStart, quarterEnd; + + if (today.isBetween(fiscalYearStart, fiscalYearStart.clone().add(2, "months").endOf("month"))) { + quarterStart = fiscalYearStart.clone().subtract(1, "year").add(9, "months"); + quarterEnd = fiscalYearStart.clone().subtract(1, "day"); + } else if (today.isBefore(fiscalYearStart.clone().add(6, "months"))) { + quarterStart = fiscalYearStart; + quarterEnd = fiscalYearStart.clone().add(2, "months").endOf("month"); + } else if (today.isBefore(fiscalYearStart.clone().add(9, "months"))) { + quarterStart = fiscalYearStart.clone().add(3, "months"); + quarterEnd = fiscalYearStart.clone().add(5, "months").endOf("month"); + } else { + quarterStart = fiscalYearStart.clone().add(6, "months"); + quarterEnd = fiscalYearStart.clone().add(8, "months").endOf("month"); + } + + return [quarterStart, quarterEnd]; + } + + var table = new DataTable("#topTaskTable", { + language: isFrench ? { url: "//cdn.datatables.net/plug-ins/2.3.2/i18n/fr-FR.json" } : undefined, + stripeClasses: [], + bSortClasses: false, + order: [[0, "desc"]], + processing: true, + serverSide: true, + retrieve: true, + lengthMenu: [ + [10, 25, 50, 100], + [10, 25, 50, 100], + ], + pageLength: 50, + orderCellsTop: true, + fixedHeader: true, + responsive: true, + dom: 'Br<"table-responsive"t>tilp', + drawCallback: function () { + fetchTotalDistinctTask(); + fetchTotalTaskCount(); + }, + buttons: [ + { + extend: 'csvHtml5', + className: 'btn btn-default', + text: isFrench ? 'Télécharger CSV' : 'Download CSV', + action: function (e, dt, button, config) { + e.preventDefault(); + var url = new URL(window.location.origin + ENDPOINTS.EXPORT_CSV); + url.search = getFilterParams().toString(); + window.location.href = url.toString(); + } + }, + { + extend: 'excelHtml5', + className: 'btn btn-default', + text: isFrench ? 'Télécharger Excel' : 'Download Excel', + action: function (e, dt, button, config) { + e.preventDefault(); + var url = new URL(window.location.origin + ENDPOINTS.EXPORT_EXCEL); + url.search = getFilterParams().toString(); + window.location.href = url.toString(); + } + } + ], + ajax: function(data, callback, settings) { + loadingSpinner.show(); + + // Debug logging for request construction + console.log("=== DataTable Request Debug ==="); + console.log("Original DataTable params:", data); + + // Filter out null or empty params before setting them + if ($("#department").val()) data.department = $("#department").val(); + if ($("#theme").val()) data.theme = $("#theme").val(); + if ($("#tasks").val()) data.tasks = $("#tasks").val(); + if ($("#group").val()) data.group = $("#group").val(); + if ($("#language").val()) data.language = $("#language").val(); + + var dateRangePickerValue = $("#dateRangePicker").val(); + if (dateRangePickerValue) { + var dateRange = $("#dateRangePicker").data("daterangepicker"); + data.startDate = dateRange.startDate.format(CONFIG.BACKEND_DATE_FORMAT); + data.endDate = dateRange.endDate.format(CONFIG.BACKEND_DATE_FORMAT); + } else { + delete data.startDate; + delete data.endDate; + } + data.taskCompletion = $("#taskCompletion").val(); + data.includeCommentsOnly = $("#commentsCheckbox").is(":checked"); + + // Log final request data + console.log("Final request params:", data); + console.log("Tasks array:", data.tasks); + console.log("Tasks array length:", data.tasks ? data.tasks.length : 0); + + // Calculate approximate URL length and determine method + var paramString = $.param(data); + var requestMethod = paramString.length > 2000 ? "POST" : "GET"; + console.log("Parameter string length:", paramString.length); + console.log("Request method will be:", requestMethod); + console.log("Full URL would be:", ENDPOINTS.TOP_TASK_DATA + "?" + paramString); + + if (paramString.length > 2000) { + console.warn("WARNING: URL length exceeds 2000 characters, switching to POST"); + } + + // Make the AJAX request with dynamic method + $.ajax({ + url: ENDPOINTS.TOP_TASK_DATA, + type: requestMethod, + data: data, + success: function(response) { + callback(response); + }, + error: function(xhr, error, thrown) { + console.error("=== DataTable AJAX Error ==="); + console.error("Status:", xhr.status); + console.error("Status Text:", xhr.statusText); + console.error("Response Text:", xhr.responseText); + console.error("Error:", error); + console.error("Thrown:", thrown); + handleError({xhr, error, thrown}, 'ERROR_RETRIEVING_DATA', 'DataTable AJAX'); + }, + complete: function() { + loadingSpinner.hide(); + } + }); + }, + columns: [ + { data: 'dateTime', title: isFrench ? 'Date' : 'Date', visible: true, width: "10%", className: "dt-left" }, + { data: 'timeStamp', title: isFrench ? 'Horodatage' : 'Time Stamp', visible: false }, + { data: 'surveyReferrer', title: isFrench ? 'Référence de l\'enquête' : 'Survey Referrer', visible: false }, + { data: 'language', title: isFrench ? 'Langue' : 'Language', visible: false }, + { data: 'device', title: isFrench ? 'Appareil' : 'Device', visible: false }, + { data: 'screener', title: isFrench ? 'Écran' : 'Screener', visible: false }, + { data: 'dept', title: isFrench ? 'Ministère' : 'Department', visible: false }, + { data: 'theme', title: isFrench ? 'Thème' : 'Theme', visible: false }, + { data: 'themeOther', title: isFrench ? 'Autre thème' : 'Theme Other', visible: false }, + { data: 'grouping', title: isFrench ? 'Regroupement' : 'Grouping', visible: false }, + { data: 'task', title: isFrench ? 'Tâche' : 'Task', visible: true, width: "20" }, + { data: 'taskOther', title: isFrench ? 'Autre tâche' : 'Task Other', visible: false }, + { data: 'taskSatisfaction', title: isFrench ? 'Satisfaction de la tâche' : 'Task Satisfaction', visible: false }, + { data: 'taskEase', title: isFrench ? 'Facilité de la tâche' : 'Task Ease', visible: false }, + { data: 'taskCompletion', title: isFrench ? 'Accomplissement de la tâche' : 'Task Completion', visible: false }, + { data: 'taskImprove', title: isFrench ? 'Améliorer la tâche' : 'Task Improve', visible: false }, + { data: 'taskImproveComment', title: isFrench ? 'Améliorer la tâche - commentaire' : 'Task Improve Comment', visible: true, width: "35%" }, + { data: 'taskWhyNot', title: isFrench ? 'Pourquoi pas' : 'Task Why Not', visible: false }, + { data: 'taskWhyNotComment', title: isFrench ? 'Tâche non complétée - commentaire' : 'Task Why Not Comment', visible: true, width: "35%"}, + { data: 'taskSampling', title: isFrench ? 'Échantillonnage de tâche' : 'Task Sampling', visible: false }, + { data: 'samplingInvitation', title: isFrench ? 'Invitation à l\'échantillonnage' : 'Sampling Invitation', visible: false }, + { data: 'samplingGC', title: isFrench ? 'Échantillonnage GC' : 'Sampling GC', visible: false }, + { data: 'samplingCanada', title: isFrench ? 'Échantillonnage Canada' : 'Sampling Canada', visible: false }, + { data: 'samplingTheme', title: isFrench ? 'Thème d\'échantillonnage' : 'Sampling Theme', visible: false }, + { data: 'samplingInstitution', title: isFrench ? 'Institution d\'échantillonnage' : 'Sampling Institution', visible: false }, + { data: 'samplingGrouping', title: isFrench ? 'Regroupement d\'échantillonnage' : 'Sampling Grouping', visible: false }, + { data: 'samplingTask', title: isFrench ? 'Tâche d\'échantillonnage' : 'Sampling Task', visible: false } + ], + }); + + // Attach loading overlay to DataTable events + attachLoadingOverlay(table, { + loadingText: isFrench ? 'Chargement des données...' : 'Loading data...', + subtext: isFrench ? 'Veuillez patienter pendant que nous filtrons vos résultats' : 'Please wait while we filter your results', + spinnerType: 'spinner' + }); + + // Hide loading overlay after initial table draw + table.on('draw.dt', function() { + loadingOverlay.hide(); + }); + + function fetchTotalDistinctTask() { + fetch(ENDPOINTS.TOTAL_DISTINCT_TASKS) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.text(); + }) + .then((totalDistinctTasks) => { + $(".stat .totalDistinctTasks").text(formatNumberWithCommas(totalDistinctTasks)); + }) + .catch((err) => { + console.warn("Error fetching total distinct tasks:", err); + }); + } + + function fetchTotalTaskCount() { + fetch(ENDPOINTS.TOTAL_TASK_COUNT) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.text(); + }) + .then((totalTaskCount) => { + $(".stat .totalTaskCount").text(formatNumberWithCommas(totalTaskCount)); + }) + .catch((err) => { + console.warn("Error fetching total task count:", err); + }); + } + + fetch(ENDPOINTS.DEPARTMENTS) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(departments => { + const departmentSelect = $("#department"); + departments.forEach(department => { + departmentSelect.append(``); + }); + }) + .catch(err => { + console.warn("Error fetching departments:", err); + }); + + $("#tasks, #taskCompletion, #commentsCheckbox, #language").on("change", function () { + table.ajax.reload(); + }); + + function resetFilters() { + $("#department").val(""); + $("#theme").val(""); + $("#group").val(""); + $("#language").val(""); + taskSelect.setData([]); + taskSelect.setSelected([]); + $("#tasks").val(""); + $("#dateRangePicker").data("daterangepicker").setStartDate(moment(earliestDate)); + $("#dateRangePicker").data("daterangepicker").setEndDate(moment(latestDate)); + $("#dateRangePicker").val(earliestDate + " - " + latestDate); + $("#commentsCheckbox").prop("checked", false); + $("#taskCompletion").val(""); + table.ajax.reload(); + } + + $(".reset-filters").on("click", resetFilters); + + $("#dateRangePicker").daterangepicker( + { + opens: "left", + startDate: moment(earliestDate), + endDate: moment(latestDate), + minDate: moment(earliestDate), + maxDate: moment(latestDate), + alwaysShowCalendars: true, + locale: { + format: CONFIG.DATE_FORMAT, + cancelLabel: isFrench ? "Effacer" : "Clear", + applyLabel: isFrench ? "Appliquer" : "Apply", + customRangeLabel: isFrench ? "Période spécifique" : "Custom Range", + firstDay: isFrench ? 1 : 0, + daysOfWeek: isFrench ? ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"] : undefined, + monthNames: isFrench ? ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"] : undefined, + }, + ranges: { + [isFrench ? "Toutes les dates" : "All Dates"]: [moment(earliestDate), moment(latestDate)], + [isFrench ? "Aujourd'hui" : "Today"]: [moment(), moment()], + [isFrench ? "Hier" : "Yesterday"]: [moment().subtract(1, "days"), moment().subtract(1, "days")], + [isFrench ? "7 derniers jours" : "Last 7 Days"]: [moment().subtract(6, "days"), moment()], + [isFrench ? "30 derniers jours" : "Last 30 Days"]: [moment().subtract(29, "days"), moment()], + [isFrench ? "Ce mois-ci" : "This Month"]: [moment().startOf("month"), moment().endOf("month")], + [isFrench ? "Le mois dernier" : "Last Month"]: [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")], + [isFrench ? "Dernier trimestre" : "Last Quarter"]: getLastFiscalQuarter(), + }, + }, + function (start, end, label) { + $("#dateRangePicker").val(start.format(CONFIG.DATE_FORMAT) + " - " + end.format(CONFIG.DATE_FORMAT)); + table.ajax.reload(); + } + ); + + $("#dateRangePicker").on("cancel.daterangepicker", function (ev, picker) { + picker.setStartDate(moment(earliestDate)); + picker.setEndDate(moment(latestDate)); + $("#dateRangePicker").val(moment(earliestDate).format(CONFIG.DATE_FORMAT) + " - " + moment(latestDate).format(CONFIG.DATE_FORMAT)); + table.ajax.reload(); + }); + + function handleDownload(url, defaultFilename, errorMessageKey) { + loadingSpinner.show(); + + fetch(url) + .then(response => { + if (response.status === 204) { + loadingSpinner.hide(); + showAlert('NO_DATA_EXPORT'); + return null; + } + if (!response.ok) { + return response.text().then(text => { + throw new Error(text); + }); + } + + const disposition = response.headers.get('Content-Disposition'); + const filename = extractFilenameFromHeader(disposition, defaultFilename); + return { blob: response.blob(), filename }; + }) + .then(result => { + if (result && result.blob) { + result.blob.then(blob => { + createDownloadLink(blob, result.filename); + setTimeout(() => { + loadingSpinner.hide(); + }, CONFIG.SPINNER_HIDE_DELAY); + }); + } + }) + .catch(error => { + handleError(error, errorMessageKey, 'File download'); + }); + } + + $("#downloadCSV").on("click", function () { + const url = new URL(window.location.origin + ENDPOINTS.EXPORT_CSV); + url.search = getFilterParams().toString(); + handleDownload(url, 'top_task_survey_export.csv', 'ERROR_CSV_DOWNLOAD'); + }); + + $("#downloadExcel").on("click", function () { + const url = new URL(window.location.origin + ENDPOINTS.EXPORT_EXCEL); + url.search = getFilterParams().toString(); + handleDownload(url, 'top_task_survey_export.xlsx', 'ERROR_EXCEL_DOWNLOAD'); + }); + tippy("#theme-tool-tip", { + content: isFrench ? "Thèmes de navigation de Canada.ca " : "Canada.ca navigation themes ", + }); + + $("#department, #theme, #commentsCheckbox, #group, #language").on("change", function () { + table.ajax.reload(); + }); + + function getFilterParams() { + var tasks = $("#tasks").val(); + var params = new URLSearchParams(); + + // Filter out null or empty params before setting them + if ($("#department").val()) params.append('department', $("#department").val()); + if ($("#theme").val()) params.append('theme', $("#theme").val()); + if ($("#group").val()) params.append('group', $("#group").val()); + if ($("#language").val()) params.append('language', $("#language").val()); + if ($("#taskCompletion").val()) params.append("taskCompletion", $("#taskCompletion").val()); //nus added + params.append('includeCommentsOnly', $("#commentsCheckbox").is(":checked")); + + if (tasks && tasks.length > 0) { + tasks.forEach(function(task) { + params.append('tasks[]', task); + }); + } + + var dateRangePickerValue = $("#dateRangePicker").val(); + if (dateRangePickerValue) { + var dateRange = $("#dateRangePicker").data('daterangepicker'); + params.append('startDate', dateRange.startDate.format(CONFIG.BACKEND_DATE_FORMAT)); + params.append('endDate', dateRange.endDate.format(CONFIG.BACKEND_DATE_FORMAT)); + } + + return params; + } + + var taskSelect = new SlimSelect({ + select: "#tasks", + settings: { + hideSelected: true, + keepOrder: true, + placeholderText: isFrench ? "Filtrer par mot-clé de la tâche" : "Filter by task keyword", + searchText: isFrench ? "Aucun résultat trouvé" : "No results found", + searchPlaceholder: isFrench ? "Recherche" : "Search", + searchingText: isFrench ? "Recherche en cours..." : "Searching...", + closeOnSelect: false, + }, + events: { + search: (search, currentData) => { + return new Promise((resolve, reject) => { + clearTimeout(taskSelect.debounceTimer); + taskSelect.debounceTimer = setTimeout(() => { + if (search.length < CONFIG.SEARCH_MIN_CHARS) { + return reject(getMessage('SEARCH_MIN_CHARS')); + } + + fetch(`${ENDPOINTS.TASK_NAMES}?search=${encodeURIComponent(search)}`, { + method: "GET", + headers: { + Accept: "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(getMessage('NETWORK_ERROR')); + } + return response.json(); + }) + .then((data) => { + const options = data + .filter((title) => { + return !currentData.some((optionData) => optionData.value === title); + }) + .map((title) => { + return { text: title, value: title }; + }); + + resolve(options); + }) + .catch((error) => { + console.error("Error fetching page titles:", error); + reject(error); + }); + }, CONFIG.DEBOUNCE_DELAY); + }); + }, + }, + }); +}); diff --git a/src/main/resources/static/menu/admin_en.html b/src/main/resources/static/menu/admin_en.html new file mode 100644 index 00000000..690a9826 --- /dev/null +++ b/src/main/resources/static/menu/admin_en.html @@ -0,0 +1,21 @@ + + + + +
+

Main navigation menu

+ +
+ + + \ No newline at end of file diff --git a/src/main/resources/static/menu/admin_fr.html b/src/main/resources/static/menu/admin_fr.html new file mode 100644 index 00000000..afd70830 --- /dev/null +++ b/src/main/resources/static/menu/admin_fr.html @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/menu/user_en.html b/src/main/resources/static/menu/user_en.html new file mode 100644 index 00000000..45ed02c0 --- /dev/null +++ b/src/main/resources/static/menu/user_en.html @@ -0,0 +1,20 @@ + + + + +
+

Main navigation menu

+ +
+ + + \ No newline at end of file diff --git a/src/main/resources/static/menu/user_fr.html b/src/main/resources/static/menu/user_fr.html new file mode 100644 index 00000000..4a13ed12 --- /dev/null +++ b/src/main/resources/static/menu/user_fr.html @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/secrets/notification.secret b/src/main/resources/static/secrets/notification.secret new file mode 100644 index 00000000..557377f6 --- /dev/null +++ b/src/main/resources/static/secrets/notification.secret @@ -0,0 +1 @@ +page_success_application-1b9cd596-6a89-4b39-a737-0fccc3d8e3b3-cc23ec82-cd02-4ea0-bd0d-94cae0391bac \ No newline at end of file diff --git a/src/main/resources/templates/default_en.html b/src/main/resources/templates/default_en.html new file mode 100644 index 00000000..c0a7fcd9 --- /dev/null +++ b/src/main/resources/templates/default_en.html @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + +
+

Alpha

+
+ +        +

This is an experimental version of Canada.ca for public testing.

+
+
+
+ + + +
+ + + + +
+
+ +
+ + + +
+ + +
+ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/default_fr.html b/src/main/resources/templates/default_fr.html new file mode 100644 index 00000000..4715b0c1 --- /dev/null +++ b/src/main/resources/templates/default_fr.html @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + +
+

Alpha

+
+ +        +

Ceci est une version expérimentale de Canada.ca destinée à être testée par le public. +

+
+
+
+ + + +
+ + + + +
+
+ +
+ + + +
+ + +
+ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/department_datalist.html b/src/main/resources/templates/department_datalist.html new file mode 100644 index 00000000..b36d5c21 --- /dev/null +++ b/src/main/resources/templates/department_datalist.html @@ -0,0 +1,576 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/error_en.html b/src/main/resources/templates/error_en.html new file mode 100644 index 00000000..136dc14f --- /dev/null +++ b/src/main/resources/templates/error_en.html @@ -0,0 +1,11 @@ + + + +
+
+

Error

+

+
+
+ + diff --git a/src/main/resources/templates/error_fr.html b/src/main/resources/templates/error_fr.html new file mode 100644 index 00000000..8c54306a --- /dev/null +++ b/src/main/resources/templates/error_fr.html @@ -0,0 +1,14 @@ + + + + +
+
+

Error

+

+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/login_en.html b/src/main/resources/templates/login_en.html new file mode 100644 index 00000000..b9dcc50f --- /dev/null +++ b/src/main/resources/templates/login_en.html @@ -0,0 +1,27 @@ + + + +
+ +
+

Please sign in

+
Invalid email or password.
+
You have been logged out.
+
+ +
+
+ +
+
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/login_fr.html b/src/main/resources/templates/login_fr.html new file mode 100644 index 00000000..3fdbe5f4 --- /dev/null +++ b/src/main/resources/templates/login_fr.html @@ -0,0 +1,28 @@ + + + +
+ +
+

Veuillez vous connecter

+
Email ou mot de passe invalide
+
Vous avez été déconnecté
+
+ +
+
+ +
+
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/pageFeedbackDashboard_en.html b/src/main/resources/templates/pageFeedbackDashboard_en.html new file mode 100644 index 00000000..72100284 --- /dev/null +++ b/src/main/resources/templates/pageFeedbackDashboard_en.html @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + +
+
+
+

Page Feedback - Dashboard

+ +
+
+
+
+
+
+
+ Total Pages with the Page Feedback Tool +
+ +
+
+ Total comments within the date range selected +
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/pageFeedbackDashboard_fr.html b/src/main/resources/templates/pageFeedbackDashboard_fr.html new file mode 100644 index 00000000..5fd1fe67 --- /dev/null +++ b/src/main/resources/templates/pageFeedbackDashboard_fr.html @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + +
+
+
+

Outil de rétroaction - Tableau de bord

+ +
+
+
+
+
+
+
+ Nombre total de pages avec l'outil de rétroaction +
+ +
+
+ Total des commentaires dans la période sélectionnée +
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/pageFeedback_en.html b/src/main/resources/templates/pageFeedback_en.html new file mode 100644 index 00000000..f56e78dc --- /dev/null +++ b/src/main/resources/templates/pageFeedback_en.html @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + +
+
+
+

Page Feedback

+ +
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/pageFeedback_fr.html b/src/main/resources/templates/pageFeedback_fr.html new file mode 100644 index 00000000..fb58ae2b --- /dev/null +++ b/src/main/resources/templates/pageFeedback_fr.html @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + +
+
+
+

Outil de rétroaction

+ +
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/reports.html b/src/main/resources/templates/reports.html new file mode 100644 index 00000000..c2f3a73c --- /dev/null +++ b/src/main/resources/templates/reports.html @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/signup_en.html b/src/main/resources/templates/signup_en.html new file mode 100644 index 00000000..cd3227dc --- /dev/null +++ b/src/main/resources/templates/signup_en.html @@ -0,0 +1,64 @@ + + +
+
+
+ +
+
+ +
+
+ + +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/signup_fr.html b/src/main/resources/templates/signup_fr.html new file mode 100644 index 00000000..1496e1f1 --- /dev/null +++ b/src/main/resources/templates/signup_fr.html @@ -0,0 +1,64 @@ + + +
+
+
+ +
+
+ +
+
+ + +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/success_en.html b/src/main/resources/templates/success_en.html new file mode 100644 index 00000000..e46eedf6 --- /dev/null +++ b/src/main/resources/templates/success_en.html @@ -0,0 +1,14 @@ + + + + +
+
+

Success

+

+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/success_fr.html b/src/main/resources/templates/success_fr.html new file mode 100644 index 00000000..026bf7ac --- /dev/null +++ b/src/main/resources/templates/success_fr.html @@ -0,0 +1,14 @@ + + + + +
+
+

Success

+

+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/topTaskSurvey_en.html b/src/main/resources/templates/topTaskSurvey_en.html new file mode 100644 index 00000000..d88f028d --- /dev/null +++ b/src/main/resources/templates/topTaskSurvey_en.html @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + +
+
+
+

GC Task Success Survey

+
+

Filter by:

+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+ +
+ + + +
+
+
+
+
+
+
+
+
+ Total unique tasks based on filters +
+ +
+
+ Total Entries based on filters +
+ +
+
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/topTaskSurvey_fr.html b/src/main/resources/templates/topTaskSurvey_fr.html new file mode 100644 index 00000000..3c2200b6 --- /dev/null +++ b/src/main/resources/templates/topTaskSurvey_fr.html @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + +
+
+
+

Sondage sur la réussite des tâches

+
+

Filtrer par:

+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+ +
+ + + +
+
+
+
+
+
+
+
+
+ Total des tâches uniques (sur la base des filtres sélectionnés) +
+ +
+
+ Total des entrées (sur la base des filtres sélectionnés) +
+ +
+
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/users_en.html b/src/main/resources/templates/users_en.html new file mode 100644 index 00000000..ef2b06dd --- /dev/null +++ b/src/main/resources/templates/users_en.html @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + +
EmailInstitutionRoleDate CreatedStatusAction
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/users_fr.html b/src/main/resources/templates/users_fr.html new file mode 100644 index 00000000..549a5ba5 --- /dev/null +++ b/src/main/resources/templates/users_fr.html @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + +
CourrielInstitutionRôleDate de créationStatutAction
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/wide_en.html b/src/main/resources/templates/wide_en.html new file mode 100644 index 00000000..780ad1e0 --- /dev/null +++ b/src/main/resources/templates/wide_en.html @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + +
+

Alpha

+
+ +        +

This is an experimental version of Canada.ca for public testing.

+
+
+
+ + + +
+ + + + +
+
+ +
+ + + +
+ + +
+ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/wide_fr.html b/src/main/resources/templates/wide_fr.html new file mode 100644 index 00000000..a027f60c --- /dev/null +++ b/src/main/resources/templates/wide_fr.html @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + +
+

Alpha

+
+ +        +

Ceci est une version expérimentale de Canada.ca destinée à être testée par le public. +

+
+
+
+ + + +
+ + + + +
+
+ +
+ + + +
+ + +
+ + + + + + + + + \ No newline at end of file From d517ff1d4da35380b38be7a1188c315889620b1d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Tue, 25 Nov 2025 14:13:52 -0500 Subject: [PATCH 003/105] feat: add base terragrunt files and folders --- terragrunt/.checkov.yml | 1 + terragrunt/Makefile | 8 ++++ terragrunt/env/common/common_variables.tf | 44 ++++++++++++++++++++ terragrunt/env/common/provider.tf | 27 ++++++++++++ terragrunt/env/production/env_vars.hcl | 18 ++++++++ terragrunt/env/root.hcl | 50 +++++++++++++++++++++++ terragrunt/env/staging/env_vars.hcl | 18 ++++++++ 7 files changed, 166 insertions(+) create mode 100644 terragrunt/.checkov.yml create mode 100644 terragrunt/Makefile create mode 100644 terragrunt/env/common/common_variables.tf create mode 100644 terragrunt/env/common/provider.tf create mode 100644 terragrunt/env/production/env_vars.hcl create mode 100644 terragrunt/env/root.hcl create mode 100644 terragrunt/env/staging/env_vars.hcl diff --git a/terragrunt/.checkov.yml b/terragrunt/.checkov.yml new file mode 100644 index 00000000..feeec450 --- /dev/null +++ b/terragrunt/.checkov.yml @@ -0,0 +1 @@ +skip-check: diff --git a/terragrunt/Makefile b/terragrunt/Makefile new file mode 100644 index 00000000..09729be1 --- /dev/null +++ b/terragrunt/Makefile @@ -0,0 +1,8 @@ +.PHONY: fmt checkov + +fmt: + terraform fmt -recursive aws &&\ + terragrunt hclfmt + +checkov: + checkov --directory=aws \ No newline at end of file diff --git a/terragrunt/env/common/common_variables.tf b/terragrunt/env/common/common_variables.tf new file mode 100644 index 00000000..149111d9 --- /dev/null +++ b/terragrunt/env/common/common_variables.tf @@ -0,0 +1,44 @@ +variable "account_id" { + description = "(Required) The account ID to perform actions on." + type = string +} + +variable "cbs_satellite_bucket_name" { + description = "(Required) Name of the Cloud Based Sensor S3 satellite bucket" + type = string +} + +variable "domain" { + description = "(Required) Domain name to deploy to" + type = string +} + +variable "env" { + description = "The current running environment" + type = string +} + +variable "product_name" { + description = "(Required) The name of the product you are deploying." + type = string +} + +variable "region" { + description = "The current AWS region" + type = string +} + +variable "billing_code" { + description = "The billing code to tag our resources with" + type = string +} + +variable "billing_tag_value" { + description = "The value we use to track billing" + type = string +} + +variable "default_tags" { + description = "The default tags we apply to all resources" + type = map(string) +} \ No newline at end of file diff --git a/terragrunt/env/common/provider.tf b/terragrunt/env/common/provider.tf new file mode 100644 index 00000000..ea28faec --- /dev/null +++ b/terragrunt/env/common/provider.tf @@ -0,0 +1,27 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.region + allowed_account_ids = [var.account_id] + # add default tags to all resources at creation or update + default_tags { + tags = var.default_tags + } +} + +provider "aws" { + alias = "us-east-1" + region = "us-east-1" + allowed_account_ids = [var.account_id] + # add default tags to all resources at creation or update + default_tags { + tags = var.default_tags + } +} diff --git a/terragrunt/env/production/env_vars.hcl b/terragrunt/env/production/env_vars.hcl new file mode 100644 index 00000000..0d6e4477 --- /dev/null +++ b/terragrunt/env/production/env_vars.hcl @@ -0,0 +1,18 @@ +inputs = { + account_id = "730335533085" + env = "production" + cost_center_code = "feedback-viewer-production" + region = "ca-central-1" + product_name = "feedback-viewer" + domain = "feedback-viewer.alpha.canada.ca" + + # dto-feedback-cj infrastructure references (must match their staging environment) + # dto-feedback-cj staging infrastructure (from terragrunt output) +# dto_feedback_cj_vpc_id = "vpc-0936ad5356ea0fe6d" +# dto_feedback_cj_vpc_private_subnet_ids = ["subnet-089ba77b72ba4e7ef", "subnet-08d75a1492fee6cbd"] +# dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" +# dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" +# dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" +# dto_feedback_cj_docdb_password_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-password" +} + diff --git a/terragrunt/env/root.hcl b/terragrunt/env/root.hcl new file mode 100644 index 00000000..0f0a428f --- /dev/null +++ b/terragrunt/env/root.hcl @@ -0,0 +1,50 @@ +locals{ + vars = read_terragrunt_config("../env_vars.hcl") +} + + +inputs = { + product_name = "feedback-viewer" + account_id = "${local.vars.inputs.account_id}" + domain = "${local.vars.inputs.domain}" + env = "${local.vars.inputs.env}" + region = "ca-central-1" + billing_code = "${local.vars.inputs.cost_center_code}" + billing_tag_value = "${local.vars.inputs.cost_center_code}" + cbs_satellite_bucket_name = "cbs-satellite-${local.vars.inputs.account_id}" + default_tags = { + CostCentre = "${local.vars.inputs.cost_center_code}" + Terraform = true + } +} + + +generate "provider" { + path = "provider.tf" + if_exists = "overwrite" + contents = file("./common/provider.tf") + +} + +generate "common_variables" { + path = "common_variables.tf" + if_exists = "overwrite" + contents = file("./common/common_variables.tf") +} + +remote_state { + backend = "s3" + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } + config = { + encrypt = true + bucket = "${local.vars.inputs.cost_center_code}-tf" + dynamodb_table = "terraform-state-lock-dynamo" + region = "ca-central-1" + key = "${path_relative_to_include()}/terraform.tfstate" + s3_bucket_tags = { CostCentre : local.vars.inputs.cost_center_code } + dynamodb_table_tags = { CostCentre : local.vars.inputs.cost_center_code } + } +} diff --git a/terragrunt/env/staging/env_vars.hcl b/terragrunt/env/staging/env_vars.hcl new file mode 100644 index 00000000..0a5e6d77 --- /dev/null +++ b/terragrunt/env/staging/env_vars.hcl @@ -0,0 +1,18 @@ +inputs = { + account_id = "992382783569" + env = "staging" + cost_center_code = "feedback-viewer-staging" + region = "ca-central-1" + product_name = "feedback-viewer" + domain = "feedback-viewer.cdssandbox.xyz" + + # dto-feedback-cj infrastructure references (must match their staging environment) + # dto-feedback-cj staging infrastructure (from terragrunt output) + dto_feedback_cj_vpc_id = "vpc-0936ad5356ea0fe6d" + dto_feedback_cj_vpc_private_subnet_ids = ["subnet-089ba77b72ba4e7ef", "subnet-08d75a1492fee6cbd"] + dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" + dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" + dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" + dto_feedback_cj_docdb_password_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-password" +} + From 0bc0292f35c73dff855db2b3a6c0e4a2ff5999ae Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 26 Nov 2025 09:21:35 -0500 Subject: [PATCH 004/105] feat: create ECR module --- terragrunt/aws/ecr/ecr.tf | 8 ++++++++ terragrunt/aws/ecr/outputs.tf | 8 ++++++++ terragrunt/env/production/ecr/terrragrunt.hcl | 7 +++++++ terragrunt/env/staging/ecr/terragrunt.hcl | 7 +++++++ 4 files changed, 30 insertions(+) create mode 100644 terragrunt/aws/ecr/ecr.tf create mode 100644 terragrunt/aws/ecr/outputs.tf create mode 100644 terragrunt/env/production/ecr/terrragrunt.hcl create mode 100644 terragrunt/env/staging/ecr/terragrunt.hcl diff --git a/terragrunt/aws/ecr/ecr.tf b/terragrunt/aws/ecr/ecr.tf new file mode 100644 index 00000000..d9a6bd90 --- /dev/null +++ b/terragrunt/aws/ecr/ecr.tf @@ -0,0 +1,8 @@ +resource "aws_ecr_repository" "feedback_viewer" { + name = var.product_name + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } +} \ No newline at end of file diff --git a/terragrunt/aws/ecr/outputs.tf b/terragrunt/aws/ecr/outputs.tf new file mode 100644 index 00000000..fc760a92 --- /dev/null +++ b/terragrunt/aws/ecr/outputs.tf @@ -0,0 +1,8 @@ +output "repository_url" { + description = "The URL of the ECR repository" + value = aws_ecr_repository.feedback_viewer.repository_url +} +output "repository_arn" { + description = "The ARN of the ECR repository" + value = aws_ecr_repository.feedback_viewer.arn +} \ No newline at end of file diff --git a/terragrunt/env/production/ecr/terrragrunt.hcl b/terragrunt/env/production/ecr/terrragrunt.hcl new file mode 100644 index 00000000..176e471a --- /dev/null +++ b/terragrunt/env/production/ecr/terrragrunt.hcl @@ -0,0 +1,7 @@ +terraform { + source = "../../../aws//ecr" +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/terragrunt/env/staging/ecr/terragrunt.hcl b/terragrunt/env/staging/ecr/terragrunt.hcl new file mode 100644 index 00000000..176e471a --- /dev/null +++ b/terragrunt/env/staging/ecr/terragrunt.hcl @@ -0,0 +1,7 @@ +terraform { + source = "../../../aws//ecr" +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file From 3916b52bdbee5843208f149c6434a4c4612b6eba Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 26 Nov 2025 12:43:59 -0500 Subject: [PATCH 005/105] feat: add hosted zone --- terragrunt/aws/hosted_zone/outputs.tf | 9 +++++++++ terragrunt/aws/hosted_zone/route_53.tf | 9 +++++++++ terragrunt/env/production/hosted_zone/terragrunt.hcl | 7 +++++++ terragrunt/env/staging/hosted_zone/terragrunt.hcl | 7 +++++++ 4 files changed, 32 insertions(+) create mode 100644 terragrunt/aws/hosted_zone/outputs.tf create mode 100644 terragrunt/aws/hosted_zone/route_53.tf create mode 100644 terragrunt/env/production/hosted_zone/terragrunt.hcl create mode 100644 terragrunt/env/staging/hosted_zone/terragrunt.hcl diff --git a/terragrunt/aws/hosted_zone/outputs.tf b/terragrunt/aws/hosted_zone/outputs.tf new file mode 100644 index 00000000..31134387 --- /dev/null +++ b/terragrunt/aws/hosted_zone/outputs.tf @@ -0,0 +1,9 @@ +output "hosted_zone_id" { + description = "Route53 hosted zone ID that will hold our DNS records" + value = aws_route53_zone.feedback_viewer.zone_id +} + +output "hosted_zone_name" { + description = "Route53 hosted zone name that will hold our DNS records" + value = aws_route53_zone.feedback_viewer.name +} diff --git a/terragrunt/aws/hosted_zone/route_53.tf b/terragrunt/aws/hosted_zone/route_53.tf new file mode 100644 index 00000000..0b4216b7 --- /dev/null +++ b/terragrunt/aws/hosted_zone/route_53.tf @@ -0,0 +1,9 @@ + resource "aws_route53_zone" "feedback_viewer" { + name = var.domain + + tags = { + Name = "${var.product_name}-zone" + CostCentre = var.billing_code + Terraform = true + } +} \ No newline at end of file diff --git a/terragrunt/env/production/hosted_zone/terragrunt.hcl b/terragrunt/env/production/hosted_zone/terragrunt.hcl new file mode 100644 index 00000000..176e471a --- /dev/null +++ b/terragrunt/env/production/hosted_zone/terragrunt.hcl @@ -0,0 +1,7 @@ +terraform { + source = "../../../aws//ecr" +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/terragrunt/env/staging/hosted_zone/terragrunt.hcl b/terragrunt/env/staging/hosted_zone/terragrunt.hcl new file mode 100644 index 00000000..176e471a --- /dev/null +++ b/terragrunt/env/staging/hosted_zone/terragrunt.hcl @@ -0,0 +1,7 @@ +terraform { + source = "../../../aws//ecr" +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file From 26820b5567428f3bc4e4b1071f2b528d66e6fdee Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 26 Nov 2025 12:45:20 -0500 Subject: [PATCH 006/105] fix: edit module name --- terragrunt/env/production/hosted_zone/terragrunt.hcl | 2 +- terragrunt/env/staging/hosted_zone/terragrunt.hcl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/terragrunt/env/production/hosted_zone/terragrunt.hcl b/terragrunt/env/production/hosted_zone/terragrunt.hcl index 176e471a..85d2c4fd 100644 --- a/terragrunt/env/production/hosted_zone/terragrunt.hcl +++ b/terragrunt/env/production/hosted_zone/terragrunt.hcl @@ -1,5 +1,5 @@ terraform { - source = "../../../aws//ecr" + source = "../../../aws//hosted_zone" } include { diff --git a/terragrunt/env/staging/hosted_zone/terragrunt.hcl b/terragrunt/env/staging/hosted_zone/terragrunt.hcl index 176e471a..85d2c4fd 100644 --- a/terragrunt/env/staging/hosted_zone/terragrunt.hcl +++ b/terragrunt/env/staging/hosted_zone/terragrunt.hcl @@ -1,5 +1,5 @@ terraform { - source = "../../../aws//ecr" + source = "../../../aws//hosted_zone" } include { From 8729cea216b6625454695b65733da9e602c1a0fa Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 26 Nov 2025 13:11:23 -0500 Subject: [PATCH 007/105] feat: add ssm module --- terragrunt/aws/ssm/inputs.tf | 17 +++++++++++ terragrunt/aws/ssm/outputs.tf | 14 +++++++++ terragrunt/aws/ssm/ssm.tf | 32 ++++++++++++++++++++ terragrunt/env/production/ssm/terragrunt.hcl | 7 +++++ terragrunt/env/staging/ssm/terragrunt.hcl | 7 +++++ 5 files changed, 77 insertions(+) create mode 100644 terragrunt/aws/ssm/inputs.tf create mode 100644 terragrunt/aws/ssm/outputs.tf create mode 100644 terragrunt/aws/ssm/ssm.tf create mode 100644 terragrunt/env/production/ssm/terragrunt.hcl create mode 100644 terragrunt/env/staging/ssm/terragrunt.hcl diff --git a/terragrunt/aws/ssm/inputs.tf b/terragrunt/aws/ssm/inputs.tf new file mode 100644 index 00000000..5f5b2c50 --- /dev/null +++ b/terragrunt/aws/ssm/inputs.tf @@ -0,0 +1,17 @@ + +variable "docdb_username" { + description = "The username of the documentdb cluseter" + sensitive = true + type = string +} + +variable "docdb_password" { + description = "The password of the documentdb cluster" + sensitive = true +} + +variable "jwt_secret_key" { + description = "The secret key used to sign JWT tokens" + sensitive = true + type = string +} \ No newline at end of file diff --git a/terragrunt/aws/ssm/outputs.tf b/terragrunt/aws/ssm/outputs.tf new file mode 100644 index 00000000..c41bc3c7 --- /dev/null +++ b/terragrunt/aws/ssm/outputs.tf @@ -0,0 +1,14 @@ +output "docdb_username" { + description = "The SSM parameter ARN for the DocumentDB username" + value = aws_ssm_parameter.docdb_username.arn +} + +output "docdb_password" { + description = "The SSM parameter ARN for the DocumentDB password" + value = aws_ssm_parameter.docdb_password.arn +} + +output "jwt_secret_key" { + description = "The SSM parameter ARN for the JWT secret key" + value = aws_ssm_parameter.jwt_secret_key.arn +} \ No newline at end of file diff --git a/terragrunt/aws/ssm/ssm.tf b/terragrunt/aws/ssm/ssm.tf new file mode 100644 index 00000000..1a9dd24f --- /dev/null +++ b/terragrunt/aws/ssm/ssm.tf @@ -0,0 +1,32 @@ + resource "aws_ssm_parameter" "docdb_username" { + name = "docdb_username" + type = "SecureString" + value = var.docdb_username + + tags = { + CostCentre = var.billing_code + Terraform = true + } +} + +resource "aws_ssm_parameter" "docdb_password" { + name = "docdb_password" + type = "SecureString" + value = var.docdb_password + + tags = { + CostCentre = var.billing_code + Terraform = true + } +} + +resource "aws_ssm_parameter" "jwt_secret_key" { + name = "jwt_secret_key" + type = "SecureString" + value = var.jwt_secret_key + + tags = { + CostCentre = var.billing_code + Terraform = true + } +} \ No newline at end of file diff --git a/terragrunt/env/production/ssm/terragrunt.hcl b/terragrunt/env/production/ssm/terragrunt.hcl new file mode 100644 index 00000000..1a669421 --- /dev/null +++ b/terragrunt/env/production/ssm/terragrunt.hcl @@ -0,0 +1,7 @@ +terraform { + source = "../../../aws//ssm" +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/terragrunt/env/staging/ssm/terragrunt.hcl b/terragrunt/env/staging/ssm/terragrunt.hcl new file mode 100644 index 00000000..1a669421 --- /dev/null +++ b/terragrunt/env/staging/ssm/terragrunt.hcl @@ -0,0 +1,7 @@ +terraform { + source = "../../../aws//ssm" +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file From 11f7f01d1e27b14d6c7e9ffbfc2ec70597fc253c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 26 Nov 2025 13:24:22 -0500 Subject: [PATCH 008/105] fix: add descriptive name --- terragrunt/aws/ssm/outputs.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/terragrunt/aws/ssm/outputs.tf b/terragrunt/aws/ssm/outputs.tf index c41bc3c7..deb5e098 100644 --- a/terragrunt/aws/ssm/outputs.tf +++ b/terragrunt/aws/ssm/outputs.tf @@ -1,14 +1,14 @@ -output "docdb_username" { +output "docdb_username_arn" { description = "The SSM parameter ARN for the DocumentDB username" value = aws_ssm_parameter.docdb_username.arn } -output "docdb_password" { +output "docdb_password_arn" { description = "The SSM parameter ARN for the DocumentDB password" value = aws_ssm_parameter.docdb_password.arn } -output "jwt_secret_key" { +output "jwt_secret_key_arn" { description = "The SSM parameter ARN for the JWT secret key" value = aws_ssm_parameter.jwt_secret_key.arn } \ No newline at end of file From cad3d924f3e8a9ed460e996cd47c0c59aacf5038 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 26 Nov 2025 14:07:19 -0500 Subject: [PATCH 009/105] feat: create iam module --- terragrunt/aws/iam/iam.tf | 56 ++++++++++++++++++++ terragrunt/aws/iam/inputs.tf | 34 ++++++++++++ terragrunt/aws/iam/outputs.tf | 4 ++ terragrunt/env/production/iam/terragrunt.hcl | 24 +++++++++ terragrunt/env/staging/iam/terragrunt.hcl | 24 +++++++++ 5 files changed, 142 insertions(+) create mode 100644 terragrunt/aws/iam/iam.tf create mode 100644 terragrunt/aws/iam/inputs.tf create mode 100644 terragrunt/aws/iam/outputs.tf create mode 100644 terragrunt/env/production/iam/terragrunt.hcl create mode 100644 terragrunt/env/staging/iam/terragrunt.hcl diff --git a/terragrunt/aws/iam/iam.tf b/terragrunt/aws/iam/iam.tf new file mode 100644 index 00000000..fe677cea --- /dev/null +++ b/terragrunt/aws/iam/iam.tf @@ -0,0 +1,56 @@ +# IAM Role definitions + +# Policy for ECS task role +data "aws_iam_policy_document" "feedback_viewer_ecs_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "feedback_viewer_ssm_policy" { + statement { + sid = "AllowSSMParameterAccess" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + resources = [ + var.docdb_username_arn, + var.docdb_password_arn, + var.jwt_secret_key_arn + ] + } +} + +resource "aws_iam_policy" "feedback_viewer_ssm_policy" { + name = "${var.product_name}-ssm-policy" + description = "Policy for ${var.product_name} ${var.env} to access SSM parameters" + policy = data.aws_iam_policy_document.feedback_viewer_ssm_policy.json + + tags = { + CostCentre = var.billing_code + Terraform = true + } +} + +resource "aws_iam_role" "feedback_viewer_ecs_role" { + name = "${var.product_name}-ecs-role" + assume_role_policy = data.aws_iam_policy_document.feedback_viewer_ecs_policy.json +} + +resource "aws_iam_role_policy_attachment" "feedback_viewer_ecs_policy" { + role = aws_iam_role.feedback_viewer_ecs_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy_attachment" "feedback_viewer_ecs_role_ssm_policy" { + policy_arn = aws_iam_policy.feedback_viewer_ssm_policy.arn + role = aws_iam_role.feedback_viewer_ecs_role.name +} \ No newline at end of file diff --git a/terragrunt/aws/iam/inputs.tf b/terragrunt/aws/iam/inputs.tf new file mode 100644 index 00000000..6c15d2d6 --- /dev/null +++ b/terragrunt/aws/iam/inputs.tf @@ -0,0 +1,34 @@ +variable "product_name" { + description = "(Required) The name of the product you are deploying." + type = string +} + +variable "env" { + description = "The current running environment" + type = string +} + +variable "billing_code" { + description = "The billing code to tag our resources with" + type = string +} + +variable "default_tags" { + description = "The default tags we apply to all resources" + type = map(string) +} + +variable "docdb_username_arn" { + description = "The ARN of the DocumentDB username SSM parameter" + type = string +} + +variable "docdb_password_arn" { + description = "The ARN of the DocumentDB password SSM parameter" + type = string +} + +variable "jwt_secret_key_arn" { + description = "The ARN of the JWT secret key SSM parameter" + type = string +} \ No newline at end of file diff --git a/terragrunt/aws/iam/outputs.tf b/terragrunt/aws/iam/outputs.tf new file mode 100644 index 00000000..0d148b00 --- /dev/null +++ b/terragrunt/aws/iam/outputs.tf @@ -0,0 +1,4 @@ +output "feedback_viewer_ecs_role_arn" { + description = "The ARN of the ECS task execution role" + value = aws_iam_role.feedback_viewer_ecs_role.arn +} \ No newline at end of file diff --git a/terragrunt/env/production/iam/terragrunt.hcl b/terragrunt/env/production/iam/terragrunt.hcl new file mode 100644 index 00000000..cd345ff3 --- /dev/null +++ b/terragrunt/env/production/iam/terragrunt.hcl @@ -0,0 +1,24 @@ +terraform { + source = "../../../aws//iam" +} + +dependency "ssm" { + config_path = "../ssm" + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + docdb_username_arn = "arn:aws:ssm:ca-central-1:123456789012:parameter/mock_docdb_username" + docdb_password_arn = "arn:aws:ssm:ca-central-1:123456789012:parameter/mock_docdb_password" + jwt_secret_key_arn = "arn:aws:ssm:ca-central-1:123456789012:parameter/mock_jwt_secret_key" + } +} + +inputs = { + docdb_username_arn = dependency.ssm.outputs.docdb_username_arn + docdb_password_arn = dependency.ssm.outputs.docdb_password_arn + jwt_secret_key_arn = dependency.ssm.outputs.jwt_secret_key_arn +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file diff --git a/terragrunt/env/staging/iam/terragrunt.hcl b/terragrunt/env/staging/iam/terragrunt.hcl new file mode 100644 index 00000000..476e0eae --- /dev/null +++ b/terragrunt/env/staging/iam/terragrunt.hcl @@ -0,0 +1,24 @@ +terraform { + source = "../../../aws//iam" +} + +dependency "ssm" { + config_path = "../ssm" + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + docdb_username_arn = "" + docdb_password_arn = "" + jwt_secret_key_arn = "" + } +} + +inputs = { + docdb_username_arn = dependency.ssm.outputs.docdb_username_arn + docdb_password_arn = dependency.ssm.outputs.docdb_password_arn + jwt_secret_key_arn = dependency.ssm.outputs.jwt_secret_key_arn +} + +include { + path = find_in_parent_folders("root.hcl") +} \ No newline at end of file From 9f69790abbc3db975a729a744fcf15033e731f67 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 08:42:44 -0500 Subject: [PATCH 010/105] feat: add required files for lb module --- terragrunt/aws/load_balancer/certificate.tf | 37 ++++++++++ terragrunt/aws/load_balancer/inputs.tf | 20 ++++++ terragrunt/aws/load_balancer/load_balancer.tf | 67 +++++++++++++++++++ terragrunt/aws/load_balancer/outputs.tf | 14 ++++ terragrunt/aws/load_balancer/route53.tf | 11 +++ .../aws/load_balancer/security_group.tf | 23 +++++++ terragrunt/env/staging/env_vars.hcl | 6 ++ .../env/staging/load_balancer/terragrunt.hcl | 30 +++++++++ 8 files changed, 208 insertions(+) create mode 100644 terragrunt/aws/load_balancer/certificate.tf create mode 100644 terragrunt/aws/load_balancer/inputs.tf create mode 100644 terragrunt/aws/load_balancer/load_balancer.tf create mode 100644 terragrunt/aws/load_balancer/outputs.tf create mode 100644 terragrunt/aws/load_balancer/route53.tf create mode 100644 terragrunt/aws/load_balancer/security_group.tf create mode 100644 terragrunt/env/staging/load_balancer/terragrunt.hcl diff --git a/terragrunt/aws/load_balancer/certificate.tf b/terragrunt/aws/load_balancer/certificate.tf new file mode 100644 index 00000000..b75c3e9b --- /dev/null +++ b/terragrunt/aws/load_balancer/certificate.tf @@ -0,0 +1,37 @@ +resource "aws_acm_certificate" "feedback_viewer" { + domain_name = var.domain + validation_method = "DNS" + + tags = merge(var.default_tags, { + Name = "${var.product_name}-cert" + CostCentre = var.billing_code + Terraform = true + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "feedback_viewer_certificate_validation" { + for_each = { + for dvo in aws_acm_certificate.feedback_viewer.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + type = dvo.resource_record_type + record = dvo.resource_record_value + zone_id = var.hosted_zone_id + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + type = each.value.type + zone_id = each.value.zone_id + ttl = 60 +} + +resource "aws_acm_certificate_validation" "feedback_viewer" { + certificate_arn = aws_acm_certificate.feedback_viewer.arn + validation_record_fqdns = [for record in aws_route53_record.feedback_viewer_certificate_validation : record.fqdn] +} diff --git a/terragrunt/aws/load_balancer/inputs.tf b/terragrunt/aws/load_balancer/inputs.tf new file mode 100644 index 00000000..a070c853 --- /dev/null +++ b/terragrunt/aws/load_balancer/inputs.tf @@ -0,0 +1,20 @@ +variable "hosted_zone_id" { + description = "The hosted zone ID to create DNS records in" + type = string +} + +variable "vpc_id" { + description = "The VPC id of the url shortener" + type = string +} + +variable "vpc_cidr_block" { + description = "IP CIDR block of the VPC" + type = string +} + +variable "vpc_public_subnet_ids" { + description = "Public subnet ids of the VPC" + type = list(string) +} + diff --git a/terragrunt/aws/load_balancer/load_balancer.tf b/terragrunt/aws/load_balancer/load_balancer.tf new file mode 100644 index 00000000..de88a65c --- /dev/null +++ b/terragrunt/aws/load_balancer/load_balancer.tf @@ -0,0 +1,67 @@ +resource "aws_lb" "feedback_viewer" { + name = "${var.product_name}-lb" + internal = false #tfsec:ignore:AWS005 + load_balancer_type = "application" + + idle_timeout = 300 + enable_deletion_protection = true + drop_invalid_header_fields = true + + security_groups = [ + aws_security_group.feedback_viewer_load_balancer_sg.id + ] + + subnets = var.vpc_public_subnet_ids + + tags = merge(var.default_tags, { + CostCentre = var.billing_code + ForceRefresh = "2025-08-14" + }) +} + +resource "aws_lb_listener" "feedback_viewer_listener" { + depends_on = [ + aws_acm_certificate.feedback_viewer, + aws_route53_record.feedback_viewer_certificate_validation, + aws_acm_certificate_validation.feedback_viewer, + ] + + load_balancer_arn = aws_lb.feedback_viewer.arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate.feedback_viewer.arn + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.feedback_viewer.arn + } + + tags = merge(var.default_tags, { + CostCentre = var.billing_code + ForceRefresh = "2025-08-14" + }) +} + +resource "aws_lb_target_group" "feedback_viewer" { + name = var.product_name + port = 3001 + protocol = "HTTP" + protocol_version = "HTTP1" + target_type = "ip" + deregistration_delay = 30 + vpc_id = var.vpc_id + + health_check { + enabled = true + interval = 60 + path = "/health" + timeout = 30 + healthy_threshold = 2 + unhealthy_threshold = 2 + } + + tags = merge(var.default_tags, { + CostCentre = var.billing_code + ForceRefresh = "2025-08-14" + }) +} diff --git a/terragrunt/aws/load_balancer/outputs.tf b/terragrunt/aws/load_balancer/outputs.tf new file mode 100644 index 00000000..9dc4c351 --- /dev/null +++ b/terragrunt/aws/load_balancer/outputs.tf @@ -0,0 +1,14 @@ +output "lb_listener" { + description = "Load balancer listener of Feedback Viewer" + value = aws_lb_listener.feedback_viewer_listener +} + +output "lb_target_group_arn" { + description = "Arn of the Load balancer target group" + value = aws_lb_target_group.feedback_viewer.arn +} + +output "feedback_viewer_load_balancer_sg" { + description = "Security group of the Load balancer" + value = aws_security_group.feedback_viewer_load_balancer_sg.id +} \ No newline at end of file diff --git a/terragrunt/aws/load_balancer/route53.tf b/terragrunt/aws/load_balancer/route53.tf new file mode 100644 index 00000000..91626398 --- /dev/null +++ b/terragrunt/aws/load_balancer/route53.tf @@ -0,0 +1,11 @@ +resource "aws_route53_record" "feedback_viewer" { + zone_id = var.hosted_zone_id + name = var.domain + type = "A" + + alias { + name = aws_lb.feedback_viewer.dns_name + zone_id = aws_lb.feedback_viewer.zone_id + evaluate_target_health = false + } +} diff --git a/terragrunt/aws/load_balancer/security_group.tf b/terragrunt/aws/load_balancer/security_group.tf new file mode 100644 index 00000000..ab8e5d7b --- /dev/null +++ b/terragrunt/aws/load_balancer/security_group.tf @@ -0,0 +1,23 @@ +resource "aws_security_group" "feedback_viewer_load_balancer_sg" { + name = "feedback_viewer_load_balancer_sg" + description = "Security group for load balancer" + vpc_id = var.vpc_id + + ingress { + protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_blocks = ["0.0.0.0/0"] + description = "Allow HTTPS inbound traffic" + } + egress { + protocol = "tcp" + from_port = 3001 + to_port = 3001 + cidr_blocks = [var.vpc_cidr_block] + } + + tags = merge(var.default_tags, { + CostCentre = var.billing_code + }) +} \ No newline at end of file diff --git a/terragrunt/env/staging/env_vars.hcl b/terragrunt/env/staging/env_vars.hcl index 0a5e6d77..9b79f76b 100644 --- a/terragrunt/env/staging/env_vars.hcl +++ b/terragrunt/env/staging/env_vars.hcl @@ -10,6 +10,12 @@ inputs = { # dto-feedback-cj staging infrastructure (from terragrunt output) dto_feedback_cj_vpc_id = "vpc-0936ad5356ea0fe6d" dto_feedback_cj_vpc_private_subnet_ids = ["subnet-089ba77b72ba4e7ef", "subnet-08d75a1492fee6cbd"] + # Public subnet IDs for the dto-feedback-cj VPC. The ALB needs public subnets to be provided here. + # Populated from: `terragrunt output vpc_public_subnet_ids` from dto-feedback-cj + dto_feedback_cj_vpc_public_subnet_ids = [ + "subnet-0b491669917639c05", + "subnet-04c4cdbafdd4d17ec", + ] dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" diff --git a/terragrunt/env/staging/load_balancer/terragrunt.hcl b/terragrunt/env/staging/load_balancer/terragrunt.hcl new file mode 100644 index 00000000..c3f9a662 --- /dev/null +++ b/terragrunt/env/staging/load_balancer/terragrunt.hcl @@ -0,0 +1,30 @@ +terraform { + source = "../../../aws//load_balancer" +} + +dependency "hosted_zone" { + config_path = "../hosted_zone" + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + hosted_zone_id = "Z1234567890ABC" + } +} + +inputs = { + # VPC configuration from dto-feedback-cj infrastructure + vpc_id = local.vars.inputs.dto_feedback_cj_vpc_id + vpc_cidr_block = local.vars.inputs.dto_feedback_cj_vpc_cidr_block + vpc_public_subnet_ids = local.vars.inputs.dto_feedback_cj_vpc_public_subnet_ids + + # DNS configuration + hosted_zone_id = dependency.hosted_zone.outputs.hosted_zone_id +} + +locals { + vars = read_terragrunt_config(find_in_parent_folders("env_vars.hcl")) +} + +include { + path = find_in_parent_folders("root.hcl") +} From d1e77da65018a9c14b61ba810b4996f5833ee078 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 12:32:17 -0500 Subject: [PATCH 011/105] fix: use updated ssl_policy and stop creating ACM cert --- terragrunt/aws/load_balancer/certificate.tf | 3 --- terragrunt/aws/load_balancer/load_balancer.tf | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/terragrunt/aws/load_balancer/certificate.tf b/terragrunt/aws/load_balancer/certificate.tf index b75c3e9b..d84017bb 100644 --- a/terragrunt/aws/load_balancer/certificate.tf +++ b/terragrunt/aws/load_balancer/certificate.tf @@ -8,9 +8,6 @@ resource "aws_acm_certificate" "feedback_viewer" { Terraform = true }) - lifecycle { - create_before_destroy = true - } } resource "aws_route53_record" "feedback_viewer_certificate_validation" { diff --git a/terragrunt/aws/load_balancer/load_balancer.tf b/terragrunt/aws/load_balancer/load_balancer.tf index de88a65c..d277d699 100644 --- a/terragrunt/aws/load_balancer/load_balancer.tf +++ b/terragrunt/aws/load_balancer/load_balancer.tf @@ -29,7 +29,7 @@ resource "aws_lb_listener" "feedback_viewer_listener" { load_balancer_arn = aws_lb.feedback_viewer.arn port = "443" protocol = "HTTPS" - ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09" certificate_arn = aws_acm_certificate.feedback_viewer.arn default_action { type = "forward" From b95816522ae42343725e5b26f23b728f9f1a5270 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 12:51:21 -0500 Subject: [PATCH 012/105] feat: add ECS module for feedback-viewer application --- terragrunt/aws/ecs/ecs.tf | 79 +++++++++++++++++ terragrunt/aws/ecs/inputs.tf | 94 ++++++++++++++++++++ terragrunt/aws/ecs/outputs.tf | 4 + terragrunt/aws/ecs/security_group.tf | 40 +++++++++ terragrunt/env/staging/ecs/terragrunt.hcl | 101 ++++++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 terragrunt/aws/ecs/ecs.tf create mode 100644 terragrunt/aws/ecs/inputs.tf create mode 100644 terragrunt/aws/ecs/outputs.tf create mode 100644 terragrunt/aws/ecs/security_group.tf create mode 100644 terragrunt/env/staging/ecs/terragrunt.hcl diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf new file mode 100644 index 00000000..d20dfb60 --- /dev/null +++ b/terragrunt/aws/ecs/ecs.tf @@ -0,0 +1,79 @@ +locals { + container_secrets = [ + { + name = "DOCDB_USERNAME" + valueFrom = var.docdb_username_arn + }, + { + name = "DOCDB_PASSWORD" + valueFrom = var.docdb_password_arn + }, + { + name = "JWT_SECRET_KEY" + valueFrom = var.jwt_secret_key_arn + } + ] +} + +module "feedback_viewer" { + source = "github.com/cds-snc/terraform-modules//ecs?ref=v10.3.0" + + # Cluster and service + cluster_name = "${var.product_name}-cluster" + service_name = "${var.product_name}-app-service" + depends_on = [ + var.lb_listener, + var.feedback_viewer-ecs-policy_attachment + ] + + # Task/Container definition + container_image = "${var.ecr_repository_url}:${var.image_tag}" + container_name = var.product_name + task_cpu = var.cpu + task_memory = var.memory + container_port = var.container_port + container_host_port = 3001 + container_secrets = local.container_secrets + container_linux_parameters = {} + container_ulimits = [ + { + "hardLimit" : 1000000, + "name" : "nofile", + "softLimit" : 1000000 + } + ] + container_read_only_root_filesystem = false + + # Task definition + task_name = "${var.product_name}-task" + task_exec_role_arn = var.iam_role_feedback-viewer-ecs-role_arn + task_role_arn = var.iam_role_feedback-viewer-ecs-role_arn + + # Scaling + enable_autoscaling = true + desired_count = var.desired_count + + # Networking + lb_target_group_arn = var.lb_target_group_arn + security_group_ids = [aws_security_group.ecs_tasks.id] + subnet_ids = var.private_subnet_ids + + # Forward logs to Sentinel + sentinel_forwarder = true + sentinel_forwarder_layer_arn = "arn:aws:lambda:ca-central-1:283582579564:layer:aws-sentinel-connector-layer:199" + + billing_tag_value = var.billing_code + + # Enabled to allow connection to DB only in staging + enable_execute_command = var.env == "staging" ? true : false +} + +resource "aws_cloudwatch_log_group" "feedback-viewer_group" { + name = "/aws/ecs/${var.product_name}-cluster" + retention_in_days = 30 +} + +resource "aws_cloudwatch_log_stream" "feedback-viewer_stream" { + name = "${var.product_name}-log-stream" + log_group_name = aws_cloudwatch_log_group.feedback-viewer_group.name +} \ No newline at end of file diff --git a/terragrunt/aws/ecs/inputs.tf b/terragrunt/aws/ecs/inputs.tf new file mode 100644 index 00000000..4c03b0c3 --- /dev/null +++ b/terragrunt/aws/ecs/inputs.tf @@ -0,0 +1,94 @@ +variable "vpc_id" { + description = "VPC ID where ECS tasks will run" + type = string +} + +variable "vpc_cidr_block" { + description = "CIDR block of the VPC" + type = string +} + +variable "private_subnet_ids" { + description = "Private subnet IDs for ECS tasks" + type = list(string) +} + +variable "iam_role_feedback-viewer-ecs-role_arn" { + description = "IAM role ARN for ECS tasks" + type = string +} + +variable "feedback_viewer-ecs-policy_attachment" { + description = "IAM policy attachment for ECS" + type = string +} + +variable "lb_listener" { + description = "Load balancer listener dependency" + type = string +} + +variable "lb_target_group_arn" { + description = "Load balancer target group ARN" + type = string +} + +variable "lb_security_group_id" { + description = "Load balancer security group ID" + type = string +} + +variable "ecr_repository_url" { + description = "ECR repository URL for container image" + type = string +} + +variable "image_tag" { + description = "Container image tag to deploy" + type = string + default = "latest" +} + +variable "container_port" { + description = "Port the container listens on" + type = number + default = 3001 +} + +variable "cpu" { + description = "Fargate task CPU units" + type = number + default = 512 +} + +variable "memory" { + description = "Fargate task memory in MB" + type = number + default = 1024 +} + +variable "desired_count" { + description = "Desired number of ECS tasks" + type = number + default = 2 +} + +variable "docdb_username_arn" { + description = "SSM parameter ARN for DocumentDB username" + type = string +} + +variable "docdb_password_arn" { + description = "SSM parameter ARN for DocumentDB password" + type = string +} + +variable "docdb_endpoint" { + description = "DocumentDB cluster endpoint" + type = string +} + +variable "jwt_secret_key_arn" { + description = "SSM parameter ARN for JWT secret key" + type = string +} \ No newline at end of file diff --git a/terragrunt/aws/ecs/outputs.tf b/terragrunt/aws/ecs/outputs.tf new file mode 100644 index 00000000..f238e731 --- /dev/null +++ b/terragrunt/aws/ecs/outputs.tf @@ -0,0 +1,4 @@ +output "ecs_tasks_security_group_id" { + description = "Id of the ECS tasks security group" + value = aws_security_group.ecs_tasks.id +} \ No newline at end of file diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf new file mode 100644 index 00000000..4b67975b --- /dev/null +++ b/terragrunt/aws/ecs/security_group.tf @@ -0,0 +1,40 @@ +### +# Security groups for ECS +### + +resource "aws_security_group" "ecs_tasks" { + name = "feedback-viewer-security-group" + description = "Allow inbound and outbound traffic for Feedback Viewer" + vpc_id = var.vpc_id + + tags = { + "CostCentre" = var.billing_code + } +} + +resource "aws_security_group_rule" "ecs_ingress_lb" { + description = "Allow the ecs security group to receive traffic only from the load balancer on port ${var.container_port}" + type = "ingress" + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + source_security_group_id = var.lb_security_group_id + security_group_id = aws_security_group.ecs_tasks.id +} + +resource "aws_security_group_rule" "ecs_egress_all" { + #checkov:skip=CKV_AWS_382 # We need to allow all traffic for ECS to work + description = "Allow ECS security group to send all traffic" + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + depends_on = [aws_security_group.ecs_tasks] + + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.ecs_tasks.id +} + +### +# Traffic to DocumentDB should only come from ECS +### diff --git a/terragrunt/env/staging/ecs/terragrunt.hcl b/terragrunt/env/staging/ecs/terragrunt.hcl new file mode 100644 index 00000000..10c1b79b --- /dev/null +++ b/terragrunt/env/staging/ecs/terragrunt.hcl @@ -0,0 +1,101 @@ +terraform { + source = "../../../aws//ecs" +} + +dependencies { + paths = ["../iam", "../ecr", "../load_balancer", "../ssm"] +} + +dependency "iam" { + config_path = "../iam" + + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + iam_role_feedback-viewer-ecs-role_arn = "" + feedback-viewer-ecs-policy_attachment = "" + } +} + + +dependency "ecr" { + config_path = "../ecr" + + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + ecr_repository_arn = "" + ecr_repository_url = "" + } +} + +dependency "load_balancer" { + config_path = "../load_balancer" + + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + lb_listener = "" + lb_target_group_arn = "" + feedback_viewer_load_balancer_sg = "" + } +} + + +dependency "ssm" { + config_path = "../ssm" + + mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] + mock_outputs_merge_with_state = true + mock_outputs = { + azure_openai_api_key_arn = "" + azure_openai_endpoint_arn = "" + azure_openai_api_version_arn = "" + canada_ca_search_uri_arn = "" + canada_ca_search_api_key_arn = "" + user_agent_arn = "" + jwt_secret_key_arn = "" + google_api_key_arn = "" + gc_notify_api_key_arn = "" + google_search_engine_id_arn = "" + adobe_analytics_url_arn = "" + } +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env_vars.hcl")) +} + +inputs = { + # From root.hcl (automatically inherited via include block) + # product_name, billing_code, env are available + + # IAM + iam_role_feedback-viewer-ecs-role_arn = dependency.iam.outputs.iam_role_feedback-viewer-ecs-role_arn + feedback_viewer-ecs-policy_attachment = dependency.iam.outputs.feedback-viewer-ecs-policy_attachment + + # Network - from env_vars.hcl (dto-feedback-cj infrastructure) + vpc_id = local.env_vars.inputs.dto_feedback_cj_vpc_id + vpc_cidr_block = local.env_vars.inputs.dto_feedback_cj_vpc_cidr_block + private_subnet_ids = local.env_vars.inputs.dto_feedback_cj_vpc_private_subnet_ids + + # Load balancer + lb_listener = dependency.load_balancer.outputs.lb_listener + lb_target_group_arn = dependency.load_balancer.outputs.lb_target_group_arn + lb_security_group_id = dependency.load_balancer.outputs.feedback_viewer_load_balancer_sg + + # ECR + ecr_repository_url = dependency.ecr.outputs.ecr_repository_url + + # DocumentDB - from env_vars.hcl (dto-feedback-cj infrastructure) + docdb_endpoint = local.env_vars.inputs.dto_feedback_cj_docdb_endpoint + docdb_username_arn = local.env_vars.inputs.dto_feedback_cj_docdb_username_arn + docdb_password_arn = local.env_vars.inputs.dto_feedback_cj_docdb_password_arn + + # Secrets + jwt_secret_key_arn = dependency.ssm.outputs.jwt_secret_key_arn +} + +include { + path = find_in_parent_folders("root.hcl") +} From 2217b945b4b073f6494f2acda9acec104a674d33 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 12:55:45 -0500 Subject: [PATCH 013/105] fix: use latest module version --- terragrunt/aws/ecs/ecs.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index d20dfb60..4d140c2a 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -16,7 +16,7 @@ locals { } module "feedback_viewer" { - source = "github.com/cds-snc/terraform-modules//ecs?ref=v10.3.0" + source = "github.com/cds-snc/terraform-modules//ecs?ref=v10.9.1" # Cluster and service cluster_name = "${var.product_name}-cluster" From fd9ab00eba615c1a31222fbdfde336ac4dd269ef Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 13:01:01 -0500 Subject: [PATCH 014/105] fix: update default values for cpu, memory, & desired counts --- terragrunt/aws/ecs/inputs.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/terragrunt/aws/ecs/inputs.tf b/terragrunt/aws/ecs/inputs.tf index 4c03b0c3..9c272a34 100644 --- a/terragrunt/aws/ecs/inputs.tf +++ b/terragrunt/aws/ecs/inputs.tf @@ -58,19 +58,19 @@ variable "container_port" { variable "cpu" { description = "Fargate task CPU units" type = number - default = 512 + default = 1024 } variable "memory" { description = "Fargate task memory in MB" type = number - default = 1024 + default = 4096 } variable "desired_count" { description = "Desired number of ECS tasks" type = number - default = 2 + default = 1 } variable "docdb_username_arn" { From 1857930ec9f0b10b611325322c4d5602adfc4a28 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 13:12:56 -0500 Subject: [PATCH 015/105] fix: remove unused variables --- terragrunt/env/staging/ecs/terragrunt.hcl | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/terragrunt/env/staging/ecs/terragrunt.hcl b/terragrunt/env/staging/ecs/terragrunt.hcl index 10c1b79b..f0b3d946 100644 --- a/terragrunt/env/staging/ecs/terragrunt.hcl +++ b/terragrunt/env/staging/ecs/terragrunt.hcl @@ -48,17 +48,7 @@ dependency "ssm" { mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] mock_outputs_merge_with_state = true mock_outputs = { - azure_openai_api_key_arn = "" - azure_openai_endpoint_arn = "" - azure_openai_api_version_arn = "" - canada_ca_search_uri_arn = "" - canada_ca_search_api_key_arn = "" - user_agent_arn = "" - jwt_secret_key_arn = "" - google_api_key_arn = "" - gc_notify_api_key_arn = "" - google_search_engine_id_arn = "" - adobe_analytics_url_arn = "" + jwt_secret_key_arn = "" } } From c8e8694f27c03a74a82891e78b9baf66e8a98034 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:01:11 -0500 Subject: [PATCH 016/105] refactor: remove DocumentDB credentials from SSM module --- terragrunt/aws/ssm/inputs.tf | 11 ----------- terragrunt/aws/ssm/outputs.tf | 10 ---------- terragrunt/aws/ssm/ssm.tf | 23 ++--------------------- 3 files changed, 2 insertions(+), 42 deletions(-) diff --git a/terragrunt/aws/ssm/inputs.tf b/terragrunt/aws/ssm/inputs.tf index 5f5b2c50..968f87d4 100644 --- a/terragrunt/aws/ssm/inputs.tf +++ b/terragrunt/aws/ssm/inputs.tf @@ -1,15 +1,4 @@ -variable "docdb_username" { - description = "The username of the documentdb cluseter" - sensitive = true - type = string -} - -variable "docdb_password" { - description = "The password of the documentdb cluster" - sensitive = true -} - variable "jwt_secret_key" { description = "The secret key used to sign JWT tokens" sensitive = true diff --git a/terragrunt/aws/ssm/outputs.tf b/terragrunt/aws/ssm/outputs.tf index deb5e098..db5af0f0 100644 --- a/terragrunt/aws/ssm/outputs.tf +++ b/terragrunt/aws/ssm/outputs.tf @@ -1,13 +1,3 @@ -output "docdb_username_arn" { - description = "The SSM parameter ARN for the DocumentDB username" - value = aws_ssm_parameter.docdb_username.arn -} - -output "docdb_password_arn" { - description = "The SSM parameter ARN for the DocumentDB password" - value = aws_ssm_parameter.docdb_password.arn -} - output "jwt_secret_key_arn" { description = "The SSM parameter ARN for the JWT secret key" value = aws_ssm_parameter.jwt_secret_key.arn diff --git a/terragrunt/aws/ssm/ssm.tf b/terragrunt/aws/ssm/ssm.tf index 1a9dd24f..37093e03 100644 --- a/terragrunt/aws/ssm/ssm.tf +++ b/terragrunt/aws/ssm/ssm.tf @@ -1,24 +1,5 @@ - resource "aws_ssm_parameter" "docdb_username" { - name = "docdb_username" - type = "SecureString" - value = var.docdb_username - - tags = { - CostCentre = var.billing_code - Terraform = true - } -} - -resource "aws_ssm_parameter" "docdb_password" { - name = "docdb_password" - type = "SecureString" - value = var.docdb_password - - tags = { - CostCentre = var.billing_code - Terraform = true - } -} +# DocumentDB credentials are managed in dto-feedback-cj repo +# Reference them via env_vars.hcl: dto_feedback_cj_docdb_username_arn and dto_feedback_cj_docdb_password_arn resource "aws_ssm_parameter" "jwt_secret_key" { name = "jwt_secret_key" From cfbbfc41adad3513f6c093b9edf049aadff131da Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:01:37 -0500 Subject: [PATCH 017/105] refactor: clean up IAM module and update staging config --- terragrunt/aws/iam/inputs.tf | 20 -------------------- terragrunt/env/staging/iam/terragrunt.hcl | 13 +++++++++---- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/terragrunt/aws/iam/inputs.tf b/terragrunt/aws/iam/inputs.tf index 6c15d2d6..87fa9ef9 100644 --- a/terragrunt/aws/iam/inputs.tf +++ b/terragrunt/aws/iam/inputs.tf @@ -1,23 +1,3 @@ -variable "product_name" { - description = "(Required) The name of the product you are deploying." - type = string -} - -variable "env" { - description = "The current running environment" - type = string -} - -variable "billing_code" { - description = "The billing code to tag our resources with" - type = string -} - -variable "default_tags" { - description = "The default tags we apply to all resources" - type = map(string) -} - variable "docdb_username_arn" { description = "The ARN of the DocumentDB username SSM parameter" type = string diff --git a/terragrunt/env/staging/iam/terragrunt.hcl b/terragrunt/env/staging/iam/terragrunt.hcl index 476e0eae..f94992a6 100644 --- a/terragrunt/env/staging/iam/terragrunt.hcl +++ b/terragrunt/env/staging/iam/terragrunt.hcl @@ -7,15 +7,20 @@ dependency "ssm" { mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] mock_outputs_merge_with_state = true mock_outputs = { - docdb_username_arn = "" - docdb_password_arn = "" jwt_secret_key_arn = "" } } +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env_vars.hcl")) +} + inputs = { - docdb_username_arn = dependency.ssm.outputs.docdb_username_arn - docdb_password_arn = dependency.ssm.outputs.docdb_password_arn + # DocumentDB credentials from dto-feedback-cj (via env_vars.hcl) + docdb_username_arn = local.env_vars.inputs.dto_feedback_cj_docdb_username_arn + docdb_password_arn = local.env_vars.inputs.dto_feedback_cj_docdb_password_arn + + # JWT secret from our SSM module jwt_secret_key_arn = dependency.ssm.outputs.jwt_secret_key_arn } From 56796f5dcd2cb28c4b714f76cafa25084bc44101 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:02:08 -0500 Subject: [PATCH 018/105] feat: add dto-feedback-cj shared infrastructure references --- terragrunt/env/production/env_vars.hcl | 12 ++++++------ terragrunt/env/root.hcl | 13 +++++++++++-- terragrunt/env/staging/env_vars.hcl | 8 ++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/terragrunt/env/production/env_vars.hcl b/terragrunt/env/production/env_vars.hcl index 0d6e4477..cec1f3c2 100644 --- a/terragrunt/env/production/env_vars.hcl +++ b/terragrunt/env/production/env_vars.hcl @@ -8,11 +8,11 @@ inputs = { # dto-feedback-cj infrastructure references (must match their staging environment) # dto-feedback-cj staging infrastructure (from terragrunt output) -# dto_feedback_cj_vpc_id = "vpc-0936ad5356ea0fe6d" -# dto_feedback_cj_vpc_private_subnet_ids = ["subnet-089ba77b72ba4e7ef", "subnet-08d75a1492fee6cbd"] -# dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" -# dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" -# dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" -# dto_feedback_cj_docdb_password_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-password" + # dto_feedback_cj_vpc_id = "vpc-0936ad5356ea0fe6d" + # dto_feedback_cj_vpc_private_subnet_ids = ["subnet-089ba77b72ba4e7ef", "subnet-08d75a1492fee6cbd"] + # dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" + # dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" + # dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" + # dto_feedback_cj_docdb_password_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-password" } diff --git a/terragrunt/env/root.hcl b/terragrunt/env/root.hcl index 0f0a428f..962c21b4 100644 --- a/terragrunt/env/root.hcl +++ b/terragrunt/env/root.hcl @@ -1,5 +1,5 @@ -locals{ - vars = read_terragrunt_config("../env_vars.hcl") +locals { + vars = read_terragrunt_config("../env_vars.hcl") } @@ -16,6 +16,15 @@ inputs = { CostCentre = "${local.vars.inputs.cost_center_code}" Terraform = true } + + # dto-feedback-cj infrastructure (from env_vars.hcl) + dto_feedback_cj_vpc_id = "${local.vars.inputs.dto_feedback_cj_vpc_id}" + dto_feedback_cj_vpc_private_subnet_ids = local.vars.inputs.dto_feedback_cj_vpc_private_subnet_ids + dto_feedback_cj_vpc_public_subnet_ids = local.vars.inputs.dto_feedback_cj_vpc_public_subnet_ids + dto_feedback_cj_vpc_cidr_block = "${local.vars.inputs.dto_feedback_cj_vpc_cidr_block}" + dto_feedback_cj_docdb_endpoint = "${local.vars.inputs.dto_feedback_cj_docdb_endpoint}" + dto_feedback_cj_docdb_username_arn = "${local.vars.inputs.dto_feedback_cj_docdb_username_arn}" + dto_feedback_cj_docdb_password_arn = "${local.vars.inputs.dto_feedback_cj_docdb_password_arn}" } diff --git a/terragrunt/env/staging/env_vars.hcl b/terragrunt/env/staging/env_vars.hcl index 9b79f76b..58f45841 100644 --- a/terragrunt/env/staging/env_vars.hcl +++ b/terragrunt/env/staging/env_vars.hcl @@ -16,9 +16,9 @@ inputs = { "subnet-0b491669917639c05", "subnet-04c4cdbafdd4d17ec", ] - dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" - dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" - dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" - dto_feedback_cj_docdb_password_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-password" + dto_feedback_cj_vpc_cidr_block = "10.0.0.0/16" + dto_feedback_cj_docdb_endpoint = "feedback-cronjob-docdb-cluster.cluster-c52kwceay79r.ca-central-1.docdb.amazonaws.com" + dto_feedback_cj_docdb_username_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-username" + dto_feedback_cj_docdb_password_arn = "arn:aws:ssm:ca-central-1:992382783569:parameter/feedback-cronjob/staging/docdb-password" } From c729e81b0faf1e513fa47b62eeec82f23772f555 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:02:43 -0500 Subject: [PATCH 019/105] style: fix indentation --- terragrunt/aws/hosted_zone/route_53.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terragrunt/aws/hosted_zone/route_53.tf b/terragrunt/aws/hosted_zone/route_53.tf index 0b4216b7..1ad863c9 100644 --- a/terragrunt/aws/hosted_zone/route_53.tf +++ b/terragrunt/aws/hosted_zone/route_53.tf @@ -1,4 +1,4 @@ - resource "aws_route53_zone" "feedback_viewer" { +resource "aws_route53_zone" "feedback_viewer" { name = var.domain tags = { From d7b9f0cf02acd612363db6efb9b93f4039f1c497 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:28:27 -0500 Subject: [PATCH 020/105] chore: update git ignore --- .gitignore | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4259aca0..71a24659 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,13 @@ buildNumber.properties **/application.properties **/application-local.properties +# Docker environment files +docker/.env +.env + +# Terraform files +.lock.hcl +.terragrunt-cache/ # System files .DS_Store @@ -29,4 +36,5 @@ buildNumber.properties kubernetes/feedback-viewer-ingress.yml # Other -google-java-format-*.jar \ No newline at end of file +google-java-format-*.jar +.terraform.lock.hcl From 2e6a6827fa5fd2a55d1bd578c7aa6dc60103c2eb Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:46:48 -0500 Subject: [PATCH 021/105] feat: add docker file and docker-compose for local testing --- docker/Dockerfile | 26 +++++++++++++++++--- docker/docker-compose.yml | 52 ++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index dcf02c9d..b6b0c6f1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,26 @@ -FROM maven:3.8.3-jdk-8 +# Build stage +FROM maven:3.8.3-jdk-8 AS build +WORKDIR /build + +# Copy pom.xml and download dependencies (cached layer) +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# Copy source and build +COPY src ./src +RUN mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:8-jre-jammy RUN rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y \ # add any necessary packages here && apt-get clean -RUN mkdir -p /app -ADD target/PageSuccess-0.0.1-SNAPSHOT.jar /app/app.jar -ENTRYPOINT ["java","-jar","/app/app.jar"] + +WORKDIR /app +COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar + +ENV SERVER_PORT=3001 +EXPOSE 3001 + +ENTRYPOINT ["java","-jar","app.jar"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2621a91f..a7d7d52c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,19 +1,49 @@ -version: '3' services: mongodb: image: mongo:latest + container_name: feedback-viewer-mongodb ports: - - 27017:27017 + - "27017:27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=admin + - MONGO_INITDB_ROOT_PASSWORD=admin123 volumes: - - mongodb_data_container:/data/db - pagesuccess: - build: . - working_dir: /app + - mongodb_data:/data/db + networks: + - feedback-network + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: docker/Dockerfile + container_name: feedback-viewer-app ports: - - 8181:8080 - - 8000:8000 - command: mvn -X clean spring-boot:run + - "3001:3001" + environment: + - SPRING_DATA_MONGODB_HOST=mongodb + - SPRING_DATA_MONGODB_PORT=27017 + - SPRING_DATA_MONGODB_DATABASE=feedback_viewer + - SPRING_DATA_MONGODB_USERNAME=admin + - SPRING_DATA_MONGODB_PASSWORD=admin123 + - SERVER_PORT=3001 + - JWT_SECRET_KEY=local-dev-secret-key-change-in-production + - DOCDB_USERNAME=admin + - DOCDB_PASSWORD=admin123 depends_on: - - mongodb + mongodb: + condition: service_healthy + networks: + - feedback-network + restart: unless-stopped + volumes: - mongodb_data_container: + mongodb_data: + +networks: + feedback-network: + driver: bridge From 9d1ce8716491b8b50b14f246a6e8b73134c38e4c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 14:49:46 -0500 Subject: [PATCH 022/105] feat: add GitHub Actions workflows for staging deployment --- .../workflows/build_and_deploy_staging.yml | 96 +++++++++++++++++++ .github/workflows/tf_apply_staging.yml | 69 +++++++++++++ .github/workflows/tf_plan_staging.yml | 62 ++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 .github/workflows/build_and_deploy_staging.yml create mode 100644 .github/workflows/tf_apply_staging.yml create mode 100644 .github/workflows/tf_plan_staging.yml diff --git a/.github/workflows/build_and_deploy_staging.yml b/.github/workflows/build_and_deploy_staging.yml new file mode 100644 index 00000000..48c55c63 --- /dev/null +++ b/.github/workflows/build_and_deploy_staging.yml @@ -0,0 +1,96 @@ +name: Build and Push to Container Registry Staging + +on: + workflow_dispatch: + push: + branches: + - main + +env: + GITHUB_SHA: ${{ github.sha }} + REGISTRY: 992382783569.dkr.ecr.ca-central-1.amazonaws.com + +permissions: + id-token: write + contents: write + +jobs: + build: + if: github.repository == 'cds-snc/dto-feedback-viewer' + runs-on: ubuntu-latest + steps: + - name: Audit DNS requests + uses: cds-snc/dns-proxy-action@main + env: + DNS_PROXY_FORWARDTOSENTINEL: "true" + DNS_PROXY_LOGANALYTICSWORKSPACEID: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + DNS_PROXY_LOGANALYTICSSHAREDKEY: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} + + - name: Checkout + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + - name: Configure aws credentials using OIDC + uses: aws-actions/configure-aws-credentials@master + with: + role-to-assume: arn:aws:iam::992382783569:role/feedback-viewer-apply + role-session-name: FeedbackViewerGitHubActions + aws-region: "ca-central-1" + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + + - name: Build container + working-directory: ./docker + run: | + docker build \ + -t $REGISTRY/feedback-viewer:$GITHUB_SHA-`date '+%Y-%m-%d'` \ + -t $REGISTRY/feedback-viewer:latest . + + - name: Push containers to Amazon ECR + working-directory: ./docker + run: | + docker push $REGISTRY/feedback-viewer:$GITHUB_SHA-`date '+%Y-%m-%d'` + docker push $REGISTRY/feedback-viewer:latest + + - name: Restart ECS + run: | + aws ecs update-service --cluster feedback-viewer-cluster --service feedback-viewer-app-service --force-new-deployment > /dev/null 2>&1 + + # Allow this step to fail so we can handle success/failure and report it to slack. By default it waits for 10 minutes + - name: Wait for ECS Stability + id: ecs_wait + continue-on-error: true + run: | + echo "Wait for 15 minutes for ECS to stabilize.." + sleep 300 # Sleep for 5 minutes. The ecs wait command waits for an additional 10 minutes + aws ecs wait services-stable \ + --cluster feedback-viewer-cluster \ + --services feedback-viewer-app-service + + # Slack notification on failed ECS wait + - name: Slack Notify Failure + if: steps.ecs_wait.outcome == 'failure' + run: | + curl -X POST -H 'Content-type: application/json' \ + --data '{"text": "❌ ECS deployment for feedback-viewer failed to stabilize."}' \ + ${{ secrets.SLACK_WEBHOOK_URL}} + + - name: Report deployment to Sentinel + if: steps.ecs_wait.outcome == 'success' + uses: cds-snc/sentinel-forward-data-action@main + with: + input_data: '{"product": "feedback-viewer", "version": "${{ github.sha }}", "repository": "${{ github.repository }}", "environment": "staging"}' + log_type: CDS_Product_Deployment_Data + log_analytics_workspace_id: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + log_analytics_workspace_key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} + + - name: Docker generate SBOM + uses: cds-snc/security-tools/.github/actions/generate-sbom@34794baf2af592913bb5b51d8df4f8d0acc49b6f # v3.2.0 + env: + TRIVY_DB_REPOSITORY: ${{ vars.TRIVY_DB_REPOSITORY }} + with: + docker_image: "${{ env.REGISTRY }}/feedback-viewer:latest" + dockerfile_path: "./docker/Dockerfile" + sbom_name: "feedback-viewer" + token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/tf_apply_staging.yml b/.github/workflows/tf_apply_staging.yml new file mode 100644 index 00000000..d42647d5 --- /dev/null +++ b/.github/workflows/tf_apply_staging.yml @@ -0,0 +1,69 @@ +name: "Terraform apply staging" + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "terragrunt/**" + - "!terragrunt/env/production/**" + - ".github/workflows/tf_apply_staging.yml" + +env: + AWS_REGION: ca-central-1 + TERRAFORM_VERSION: 1.10.4 + TERRAGRUNT_VERSION: 0.72.3 + TF_VAR_jwt_secret_key: ${{ secrets.JWT_SECRET_KEY }} + TF_VAR_sentinel_customer_id: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + TF_VAR_sentinel_shared_key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} + +permissions: + id-token: write + contents: read + pull-requests: write + actions: write + checks: write + statuses: write + +jobs: + terragrunt-apply: + if: github.repository == 'cds-snc/dto-feedback-viewer' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Terraform Tools + uses: cds-snc/terraform-tools-setup@v1 + + - name: Configure aws credentials using OIDC + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: arn:aws:iam::992382783569:role/feedback-viewer-apply + role-session-name: TFApply + aws-region: ${{ env.AWS_REGION }} + + - name: Apply ssm + working-directory: terragrunt/env/staging/ssm + run: terragrunt apply --terragrunt-non-interactive -auto-approve + + - name: Apply hosted zone + working-directory: terragrunt/env/staging/hosted_zone + run: terragrunt apply --terragrunt-non-interactive -auto-approve + + - name: Apply ecr + working-directory: terragrunt/env/staging/ecr + run: terragrunt apply --terragrunt-non-interactive -auto-approve + + - name: Apply iam + working-directory: terragrunt/env/staging/iam + run: terragrunt apply --terragrunt-non-interactive -auto-approve + + - name: Apply load balancer + working-directory: terragrunt/env/staging/load_balancer + run: terragrunt apply --terragrunt-non-interactive -auto-approve + + - name: Apply ecs + working-directory: terragrunt/env/staging/ecs + run: terragrunt apply --terragrunt-non-interactive -auto-approve diff --git a/.github/workflows/tf_plan_staging.yml b/.github/workflows/tf_plan_staging.yml new file mode 100644 index 00000000..72e97153 --- /dev/null +++ b/.github/workflows/tf_plan_staging.yml @@ -0,0 +1,62 @@ +name: "Terraform plan staging" + +on: + workflow_dispatch: + pull_request: + paths: + - "terragrunt/**" + - "!terragrunt/env/production/**" + - ".github/workflows/tf_plan_staging.yml" + +env: + AWS_REGION: ca-central-1 + TERRAFORM_VERSION: 1.11.2 + TERRAGRUNT_VERSION: 0.75.6 + TF_VAR_jwt_secret_key: ${{ secrets.JWT_SECRET_KEY }} + TF_VAR_sentinel_customer_id: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} + TF_VAR_sentinel_shared_key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} + +permissions: + id-token: write + contents: read + pull-requests: write + actions: write + checks: write + statuses: write + +jobs: + terraform-plan: + if: github.repository == 'cds-snc/dto-feedback-viewer' + strategy: + fail-fast: false + matrix: + include: + - module: ecr + - module: ssm + - module: hosted_zone + - module: load_balancer + - module: iam + - module: ecs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Terraform Tools + uses: cds-snc/terraform-tools-setup@v1 + + - name: Configure aws credentials using OIDC + uses: aws-actions/configure-aws-credentials@v4.0.2 + with: + role-to-assume: arn:aws:iam::992382783569:role/feedback-viewer-plan + role-session-name: TFPlan + aws-region: ${{ env.AWS_REGION }} + + - name: Terraform plan + uses: cds-snc/terraform-plan@7d4ab0010e76b09dfc1d8a343d2c7ddaa2b4df4f # v3.3.2 + with: + comment-delete: true + comment-title: "Staging: ${{ matrix.module }}" + directory: ./terragrunt/env/staging/${{ matrix.module }} + github-token: ${{ secrets.GITHUB_TOKEN }} + terragrunt: true From e01a92cbce173b0be13a5db8e93345ff91c4fbc1 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 27 Nov 2025 15:28:49 -0500 Subject: [PATCH 023/105] fix: update role names --- .github/workflows/build_and_deploy_staging.yml | 2 +- .github/workflows/tf_apply_staging.yml | 2 +- .github/workflows/tf_plan_staging.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_and_deploy_staging.yml b/.github/workflows/build_and_deploy_staging.yml index 48c55c63..f80ff88d 100644 --- a/.github/workflows/build_and_deploy_staging.yml +++ b/.github/workflows/build_and_deploy_staging.yml @@ -32,7 +32,7 @@ jobs: - name: Configure aws credentials using OIDC uses: aws-actions/configure-aws-credentials@master with: - role-to-assume: arn:aws:iam::992382783569:role/feedback-viewer-apply + role-to-assume: arn:aws:iam::992382783569:role/dto-feedback-viewer-apply role-session-name: FeedbackViewerGitHubActions aws-region: "ca-central-1" diff --git a/.github/workflows/tf_apply_staging.yml b/.github/workflows/tf_apply_staging.yml index d42647d5..4e1bdd52 100644 --- a/.github/workflows/tf_apply_staging.yml +++ b/.github/workflows/tf_apply_staging.yml @@ -40,7 +40,7 @@ jobs: - name: Configure aws credentials using OIDC uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 with: - role-to-assume: arn:aws:iam::992382783569:role/feedback-viewer-apply + role-to-assume: arn:aws:iam::992382783569:role/dto-feedback-viewer-apply role-session-name: TFApply aws-region: ${{ env.AWS_REGION }} diff --git a/.github/workflows/tf_plan_staging.yml b/.github/workflows/tf_plan_staging.yml index 72e97153..b3f143e3 100644 --- a/.github/workflows/tf_plan_staging.yml +++ b/.github/workflows/tf_plan_staging.yml @@ -48,7 +48,7 @@ jobs: - name: Configure aws credentials using OIDC uses: aws-actions/configure-aws-credentials@v4.0.2 with: - role-to-assume: arn:aws:iam::992382783569:role/feedback-viewer-plan + role-to-assume: arn:aws:iam::992382783569:role/dto-feedback-viewer-plan role-session-name: TFPlan aws-region: ${{ env.AWS_REGION }} From 7c947a8b61f0931ee6c9eb9f333cb1e38c442569 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 09:01:58 -0500 Subject: [PATCH 024/105] fix: version mismatch --- .github/workflows/tf_apply_staging.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tf_apply_staging.yml b/.github/workflows/tf_apply_staging.yml index 4e1bdd52..6f8a5105 100644 --- a/.github/workflows/tf_apply_staging.yml +++ b/.github/workflows/tf_apply_staging.yml @@ -12,8 +12,8 @@ on: env: AWS_REGION: ca-central-1 - TERRAFORM_VERSION: 1.10.4 - TERRAGRUNT_VERSION: 0.72.3 + TERRAFORM_VERSION: 1.11.2 + TERRAGRUNT_VERSION: 0.75.6 TF_VAR_jwt_secret_key: ${{ secrets.JWT_SECRET_KEY }} TF_VAR_sentinel_customer_id: ${{ secrets.LOG_ANALYTICS_WORKSPACE_ID }} TF_VAR_sentinel_shared_key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} From 4f54af6fc69036ce9db88fcaf37cce4ebfbf12e5 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:49:22 -0500 Subject: [PATCH 025/105] Update .github/workflows/tf_apply_staging.yml Co-authored-by: Pat Heard --- .github/workflows/tf_apply_staging.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/tf_apply_staging.yml b/.github/workflows/tf_apply_staging.yml index 6f8a5105..6fe50fe5 100644 --- a/.github/workflows/tf_apply_staging.yml +++ b/.github/workflows/tf_apply_staging.yml @@ -21,10 +21,6 @@ env: permissions: id-token: write contents: read - pull-requests: write - actions: write - checks: write - statuses: write jobs: terragrunt-apply: From c24f68834ddac45baa9cfe9d1b4660849ec2c29f Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:50:08 -0500 Subject: [PATCH 026/105] Update .github/workflows/tf_plan_staging.yml Co-authored-by: Pat Heard --- .github/workflows/tf_plan_staging.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/tf_plan_staging.yml b/.github/workflows/tf_plan_staging.yml index b3f143e3..a5d483ef 100644 --- a/.github/workflows/tf_plan_staging.yml +++ b/.github/workflows/tf_plan_staging.yml @@ -20,9 +20,6 @@ permissions: id-token: write contents: read pull-requests: write - actions: write - checks: write - statuses: write jobs: terraform-plan: From e7a113691ae81ea0681cd9c7f23bf3faa8c7511f Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:53:51 -0500 Subject: [PATCH 027/105] Update docker/Dockerfile Co-authored-by: Pat Heard --- docker/Dockerfile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b6b0c6f1..69ec8624 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,9 +13,7 @@ RUN mvn clean package -DskipTests # Runtime stage FROM eclipse-temurin:8-jre-jammy RUN rm -rf /var/lib/apt/lists/* -RUN apt-get update && apt-get install -y \ - # add any necessary packages here - && apt-get clean +RUN apt-get update && apt-get clean WORKDIR /app COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar From 909bdaa2fc168cc9dd4c2b105b7c330f39e7457a Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:54:22 -0500 Subject: [PATCH 028/105] Update .gitignore Co-authored-by: Pat Heard --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 71a24659..885735a5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,6 @@ docker/.env .env # Terraform files -.lock.hcl .terragrunt-cache/ # System files .DS_Store From e30973aabb5ce5743c8536daa8a4a849af0d530a Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:54:36 -0500 Subject: [PATCH 029/105] Update .gitignore Co-authored-by: Pat Heard --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 885735a5..4414ad16 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,3 @@ kubernetes/feedback-viewer-ingress.yml # Other google-java-format-*.jar -.terraform.lock.hcl From 441fe28c54fc347f5ec311d485b776665cc9b84e Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 10:36:24 -0500 Subject: [PATCH 030/105] refactor: convert LB security group to use separate rule resources --- .../aws/load_balancer/security_group.tf | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/terragrunt/aws/load_balancer/security_group.tf b/terragrunt/aws/load_balancer/security_group.tf index ab8e5d7b..a19d6645 100644 --- a/terragrunt/aws/load_balancer/security_group.tf +++ b/terragrunt/aws/load_balancer/security_group.tf @@ -3,21 +3,27 @@ resource "aws_security_group" "feedback_viewer_load_balancer_sg" { description = "Security group for load balancer" vpc_id = var.vpc_id - ingress { - protocol = "tcp" - from_port = 443 - to_port = 443 - cidr_blocks = ["0.0.0.0/0"] - description = "Allow HTTPS inbound traffic" - } - egress { - protocol = "tcp" - from_port = 3001 - to_port = 3001 - cidr_blocks = [var.vpc_cidr_block] - } - tags = merge(var.default_tags, { CostCentre = var.billing_code }) +} + +resource "aws_security_group_rule" "lb_ingress_https" { + description = "Allow HTTPS inbound traffic" + type = "ingress" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.feedback_viewer_load_balancer_sg.id +} + +resource "aws_security_group_rule" "lb_egress_ecs" { + description = "Allow load balancer to send traffic to ECS tasks on port 3001" + type = "egress" + from_port = 3001 + to_port = 3001 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr_block] + security_group_id = aws_security_group.feedback_viewer_load_balancer_sg.id } \ No newline at end of file From d56a5214d35c102ef663854c95e8aab47739d9f7 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 10:42:09 -0500 Subject: [PATCH 031/105] fix: update ssm value to not conflict --- terragrunt/aws/ssm/ssm.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terragrunt/aws/ssm/ssm.tf b/terragrunt/aws/ssm/ssm.tf index 37093e03..60cf399e 100644 --- a/terragrunt/aws/ssm/ssm.tf +++ b/terragrunt/aws/ssm/ssm.tf @@ -2,7 +2,7 @@ # Reference them via env_vars.hcl: dto_feedback_cj_docdb_username_arn and dto_feedback_cj_docdb_password_arn resource "aws_ssm_parameter" "jwt_secret_key" { - name = "jwt_secret_key" + name = "/feedback-viewer/staging/jwt_secret_key" type = "SecureString" value = var.jwt_secret_key From c228eabd76a49968f652a7891e4763ecf7d4199c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 10:52:44 -0500 Subject: [PATCH 032/105] fix: correct Terragrunt output references --- terragrunt/env/staging/ecs/terragrunt.hcl | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/terragrunt/env/staging/ecs/terragrunt.hcl b/terragrunt/env/staging/ecs/terragrunt.hcl index f0b3d946..26c495d5 100644 --- a/terragrunt/env/staging/ecs/terragrunt.hcl +++ b/terragrunt/env/staging/ecs/terragrunt.hcl @@ -12,8 +12,7 @@ dependency "iam" { mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] mock_outputs_merge_with_state = true mock_outputs = { - iam_role_feedback-viewer-ecs-role_arn = "" - feedback-viewer-ecs-policy_attachment = "" + feedback_viewer_ecs_role_arn = "" } } @@ -24,8 +23,8 @@ dependency "ecr" { mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] mock_outputs_merge_with_state = true mock_outputs = { - ecr_repository_arn = "" - ecr_repository_url = "" + repository_arn = "" + repository_url = "" } } @@ -61,8 +60,7 @@ inputs = { # product_name, billing_code, env are available # IAM - iam_role_feedback-viewer-ecs-role_arn = dependency.iam.outputs.iam_role_feedback-viewer-ecs-role_arn - feedback_viewer-ecs-policy_attachment = dependency.iam.outputs.feedback-viewer-ecs-policy_attachment + iam_role_arn = dependency.iam.outputs.feedback_viewer_ecs_role_arn # Network - from env_vars.hcl (dto-feedback-cj infrastructure) vpc_id = local.env_vars.inputs.dto_feedback_cj_vpc_id @@ -75,7 +73,7 @@ inputs = { lb_security_group_id = dependency.load_balancer.outputs.feedback_viewer_load_balancer_sg # ECR - ecr_repository_url = dependency.ecr.outputs.ecr_repository_url + ecr_repository_url = dependency.ecr.outputs.repository_url # DocumentDB - from env_vars.hcl (dto-feedback-cj infrastructure) docdb_endpoint = local.env_vars.inputs.dto_feedback_cj_docdb_endpoint From da0545141810c9011691cdb1f04679986c26f3ef Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 11:07:37 -0500 Subject: [PATCH 033/105] fix: align ECS module variable names with dependency outputs --- terragrunt/aws/ecs/ecs.tf | 7 +++---- terragrunt/aws/ecs/inputs.tf | 7 +------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index 4d140c2a..6df88e8b 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -22,8 +22,7 @@ module "feedback_viewer" { cluster_name = "${var.product_name}-cluster" service_name = "${var.product_name}-app-service" depends_on = [ - var.lb_listener, - var.feedback_viewer-ecs-policy_attachment + var.lb_listener ] # Task/Container definition @@ -46,8 +45,8 @@ module "feedback_viewer" { # Task definition task_name = "${var.product_name}-task" - task_exec_role_arn = var.iam_role_feedback-viewer-ecs-role_arn - task_role_arn = var.iam_role_feedback-viewer-ecs-role_arn + task_exec_role_arn = var.iam_role_arn + task_role_arn = var.iam_role_arn # Scaling enable_autoscaling = true diff --git a/terragrunt/aws/ecs/inputs.tf b/terragrunt/aws/ecs/inputs.tf index 9c272a34..4e66a719 100644 --- a/terragrunt/aws/ecs/inputs.tf +++ b/terragrunt/aws/ecs/inputs.tf @@ -13,16 +13,11 @@ variable "private_subnet_ids" { type = list(string) } -variable "iam_role_feedback-viewer-ecs-role_arn" { +variable "iam_role_arn" { description = "IAM role ARN for ECS tasks" type = string } -variable "feedback_viewer-ecs-policy_attachment" { - description = "IAM policy attachment for ECS" - type = string -} - variable "lb_listener" { description = "Load balancer listener dependency" type = string From 863e6a7fdea690f63e85689f9df01338429ea634 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 13:39:35 -0500 Subject: [PATCH 034/105] refactor: clean up ECS module dependencies --- terragrunt/aws/ecs/ecs.tf | 3 --- terragrunt/aws/ecs/inputs.tf | 5 ----- terragrunt/env/staging/ecs/terragrunt.hcl | 2 -- 3 files changed, 10 deletions(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index 6df88e8b..c2dd4aa8 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -21,9 +21,6 @@ module "feedback_viewer" { # Cluster and service cluster_name = "${var.product_name}-cluster" service_name = "${var.product_name}-app-service" - depends_on = [ - var.lb_listener - ] # Task/Container definition container_image = "${var.ecr_repository_url}:${var.image_tag}" diff --git a/terragrunt/aws/ecs/inputs.tf b/terragrunt/aws/ecs/inputs.tf index 4e66a719..a22decf4 100644 --- a/terragrunt/aws/ecs/inputs.tf +++ b/terragrunt/aws/ecs/inputs.tf @@ -18,11 +18,6 @@ variable "iam_role_arn" { type = string } -variable "lb_listener" { - description = "Load balancer listener dependency" - type = string -} - variable "lb_target_group_arn" { description = "Load balancer target group ARN" type = string diff --git a/terragrunt/env/staging/ecs/terragrunt.hcl b/terragrunt/env/staging/ecs/terragrunt.hcl index 26c495d5..eb5590eb 100644 --- a/terragrunt/env/staging/ecs/terragrunt.hcl +++ b/terragrunt/env/staging/ecs/terragrunt.hcl @@ -34,7 +34,6 @@ dependency "load_balancer" { mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] mock_outputs_merge_with_state = true mock_outputs = { - lb_listener = "" lb_target_group_arn = "" feedback_viewer_load_balancer_sg = "" } @@ -68,7 +67,6 @@ inputs = { private_subnet_ids = local.env_vars.inputs.dto_feedback_cj_vpc_private_subnet_ids # Load balancer - lb_listener = dependency.load_balancer.outputs.lb_listener lb_target_group_arn = dependency.load_balancer.outputs.lb_target_group_arn lb_security_group_id = dependency.load_balancer.outputs.feedback_viewer_load_balancer_sg From 8ce5e74ea460e4b319641f383bc895de0827f3d5 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 14:36:59 -0500 Subject: [PATCH 035/105] fix: update layer version --- terragrunt/aws/ecs/ecs.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index c2dd4aa8..302be27d 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -56,7 +56,7 @@ module "feedback_viewer" { # Forward logs to Sentinel sentinel_forwarder = true - sentinel_forwarder_layer_arn = "arn:aws:lambda:ca-central-1:283582579564:layer:aws-sentinel-connector-layer:199" + sentinel_forwarder_layer_arn = "arn:aws:lambda:ca-central-1:283582579564:layer:aws-sentinel-connector-layer:227" billing_tag_value = var.billing_code From 73efdfe7fe02f7d43bf6761e08d05951bd991734 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 15:21:40 -0500 Subject: [PATCH 036/105] fix: update layer version --- terragrunt/aws/ecs/ecs.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index 302be27d..a34e699f 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -56,7 +56,7 @@ module "feedback_viewer" { # Forward logs to Sentinel sentinel_forwarder = true - sentinel_forwarder_layer_arn = "arn:aws:lambda:ca-central-1:283582579564:layer:aws-sentinel-connector-layer:227" + sentinel_forwarder_layer_arn = "arn:aws:lambda:ca-central-1:283582579564:layer:aws-sentinel-connector-layer:238" billing_tag_value = var.billing_code From 1966ad5f39b10ae0d41e02fc0504b206fa3388f4 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 15:31:54 -0500 Subject: [PATCH 037/105] fix: docker build path --- .github/workflows/build_and_deploy_staging.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_and_deploy_staging.yml b/.github/workflows/build_and_deploy_staging.yml index f80ff88d..fae86f09 100644 --- a/.github/workflows/build_and_deploy_staging.yml +++ b/.github/workflows/build_and_deploy_staging.yml @@ -41,14 +41,13 @@ jobs: uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 - name: Build container - working-directory: ./docker run: | docker build \ -t $REGISTRY/feedback-viewer:$GITHUB_SHA-`date '+%Y-%m-%d'` \ - -t $REGISTRY/feedback-viewer:latest . + -t $REGISTRY/feedback-viewer:latest \ + -f docker/Dockerfile . - name: Push containers to Amazon ECR - working-directory: ./docker run: | docker push $REGISTRY/feedback-viewer:$GITHUB_SHA-`date '+%Y-%m-%d'` docker push $REGISTRY/feedback-viewer:latest From c16707e6558925b1b2050f51ff66d66620475124 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 15:51:11 -0500 Subject: [PATCH 038/105] fix: update secrets needed for application --- terragrunt/aws/ecs/ecs.tf | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index a34e699f..ee8515d6 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -29,7 +29,38 @@ module "feedback_viewer" { task_memory = var.memory container_port = var.container_port container_host_port = 3001 - container_secrets = local.container_secrets + container_secrets = [ + { + name = "SPRING_DATA_MONGODB_USERNAME" + valueFrom = var.docdb_username_arn + }, + { + name = "SPRING_DATA_MONGODB_PASSWORD" + valueFrom = var.docdb_password_arn + }, + { + name = "JWT_SECRET_KEY" + valueFrom = var.jwt_secret_key_arn + } + ] + container_environment = [ + { + name = "SPRING_DATA_MONGODB_HOST" + value = var.docdb_endpoint + }, + { + name = "SPRING_DATA_MONGODB_PORT" + value = "27017" + }, + { + name = "SPRING_DATA_MONGODB_DATABASE" + value = "pagesuccess" + }, + { + name = "SPRING_DATA_MONGODB_SSL_ENABLED" + value = "true" + } + ] container_linux_parameters = {} container_ulimits = [ { From e62b6a6a6999b9fc43b33320801eb419e3f6ee41 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Fri, 28 Nov 2025 15:55:28 -0500 Subject: [PATCH 039/105] fix: format --- terragrunt/aws/ecs/ecs.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index ee8515d6..de7e74c7 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -23,12 +23,12 @@ module "feedback_viewer" { service_name = "${var.product_name}-app-service" # Task/Container definition - container_image = "${var.ecr_repository_url}:${var.image_tag}" - container_name = var.product_name - task_cpu = var.cpu - task_memory = var.memory - container_port = var.container_port - container_host_port = 3001 + container_image = "${var.ecr_repository_url}:${var.image_tag}" + container_name = var.product_name + task_cpu = var.cpu + task_memory = var.memory + container_port = var.container_port + container_host_port = 3001 container_secrets = [ { name = "SPRING_DATA_MONGODB_USERNAME" From 4866b52da1e8132cc6537b230507a4738d55b795 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 07:46:02 -0500 Subject: [PATCH 040/105] fix: allow egress traffic from the ECS tasks to DocumentDB --- terragrunt/aws/ecs/security_group.tf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf index 4b67975b..4a9b86ab 100644 --- a/terragrunt/aws/ecs/security_group.tf +++ b/terragrunt/aws/ecs/security_group.tf @@ -35,6 +35,16 @@ resource "aws_security_group_rule" "ecs_egress_all" { security_group_id = aws_security_group.ecs_tasks.id } +resource "aws_security_group_rule" "ecs_egress_docdb" { + description = "Allow ECS security group to send traffic to DocumentDB" + type = "egress" + from_port = 27017 + to_port = 27017 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.ecs_tasks.id +} + ### # Traffic to DocumentDB should only come from ECS ### From 7e75719c2178006832ec711425be7967e263e299 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 08:59:30 -0500 Subject: [PATCH 041/105] fix: install cert for docdb --- docker/Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 69ec8624..76917cba 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,9 +13,15 @@ RUN mvn clean package -DskipTests # Runtime stage FROM eclipse-temurin:8-jre-jammy RUN rm -rf /var/lib/apt/lists/* -RUN apt-get update && apt-get clean +RUN apt-get update && apt-get install -y wget && apt-get clean WORKDIR /app + +# Download AWS RDS CA bundle +RUN wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \ + && keytool -import -alias aws-rds-global -file global-bundle.pem -keystore $JAVA_HOME/lib/security/cacerts -storepass changeit -noprompt \ + && rm global-bundle.pem + COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar ENV SERVER_PORT=3001 From d2fc99baaa12ebf49051a10cf68515a5eca55368 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 10:08:27 -0500 Subject: [PATCH 042/105] fix: update secrets --- terragrunt/aws/ecs/ecs.tf | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index de7e74c7..5e5df177 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -45,20 +45,8 @@ module "feedback_viewer" { ] container_environment = [ { - name = "SPRING_DATA_MONGODB_HOST" - value = var.docdb_endpoint - }, - { - name = "SPRING_DATA_MONGODB_PORT" - value = "27017" - }, - { - name = "SPRING_DATA_MONGODB_DATABASE" - value = "pagesuccess" - }, - { - name = "SPRING_DATA_MONGODB_SSL_ENABLED" - value = "true" + name = "SPRING_DATA_MONGODB_URI" + value = "mongodb://$${SPRING_DATA_MONGODB_USERNAME}:$${SPRING_DATA_MONGODB_PASSWORD}@${var.docdb_endpoint}:27017/pagesuccess?ssl=true&retryWrites=false&tlsAllowInvalidHostnames=true" } ] container_linux_parameters = {} From ad8778ad88b201dc85db2b517dcfe02ee30d82d3 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 10:13:17 -0500 Subject: [PATCH 043/105] fix: remove redundant rule --- terragrunt/aws/ecs/security_group.tf | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf index 4a9b86ab..5d5a0c40 100644 --- a/terragrunt/aws/ecs/security_group.tf +++ b/terragrunt/aws/ecs/security_group.tf @@ -34,17 +34,3 @@ resource "aws_security_group_rule" "ecs_egress_all" { cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.ecs_tasks.id } - -resource "aws_security_group_rule" "ecs_egress_docdb" { - description = "Allow ECS security group to send traffic to DocumentDB" - type = "egress" - from_port = 27017 - to_port = 27017 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - security_group_id = aws_security_group.ecs_tasks.id -} - -### -# Traffic to DocumentDB should only come from ECS -### From c6e1abf99324fbd610132a0177b3b12d602a9e85 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 10:20:26 -0500 Subject: [PATCH 044/105] fix: update ports --- terragrunt/aws/ecs/security_group.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf index 5d5a0c40..6db123e5 100644 --- a/terragrunt/aws/ecs/security_group.tf +++ b/terragrunt/aws/ecs/security_group.tf @@ -26,8 +26,8 @@ resource "aws_security_group_rule" "ecs_egress_all" { #checkov:skip=CKV_AWS_382 # We need to allow all traffic for ECS to work description = "Allow ECS security group to send all traffic" type = "egress" - from_port = 0 - to_port = 0 + from_port = -1 + to_port = -1 protocol = "-1" depends_on = [aws_security_group.ecs_tasks] From e8ffe9f427f8a6b69f81fbf08562112682abb3d6 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 12:24:13 -0500 Subject: [PATCH 045/105] fix: try removing from/to ports --- terragrunt/aws/ecs/security_group.tf | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf index 6db123e5..84f222e2 100644 --- a/terragrunt/aws/ecs/security_group.tf +++ b/terragrunt/aws/ecs/security_group.tf @@ -24,13 +24,10 @@ resource "aws_security_group_rule" "ecs_ingress_lb" { resource "aws_security_group_rule" "ecs_egress_all" { #checkov:skip=CKV_AWS_382 # We need to allow all traffic for ECS to work - description = "Allow ECS security group to send all traffic" - type = "egress" - from_port = -1 - to_port = -1 - protocol = "-1" - depends_on = [aws_security_group.ecs_tasks] - + description = "Allow ECS security group to send all traffic" + type = "egress" + protocol = "-1" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.ecs_tasks.id + depends_on = [aws_security_group.ecs_tasks] } From e7be4c86580c94e92126c394b062a5547d3080b8 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 12:33:32 -0500 Subject: [PATCH 046/105] fix: revert security_group.tf --- terragrunt/aws/ecs/security_group.tf | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf index 84f222e2..5acb2848 100644 --- a/terragrunt/aws/ecs/security_group.tf +++ b/terragrunt/aws/ecs/security_group.tf @@ -24,10 +24,27 @@ resource "aws_security_group_rule" "ecs_ingress_lb" { resource "aws_security_group_rule" "ecs_egress_all" { #checkov:skip=CKV_AWS_382 # We need to allow all traffic for ECS to work - description = "Allow ECS security group to send all traffic" + description = "Allow ECS security group to send all traffic" + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + depends_on = [aws_security_group.ecs_tasks] + + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.ecs_tasks.id +} + +resource "aws_security_group_rule" "ecs_egress_docdb" { + description = "Allow ECS security group to send traffic to DocumentDB" type = "egress" - protocol = "-1" + from_port = 27017 + to_port = 27017 + protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.ecs_tasks.id - depends_on = [aws_security_group.ecs_tasks] } + +### +# Traffic to DocumentDB should only come from ECS +### \ No newline at end of file From e228c208d73dc6d038310e082d464d6ac739fd73 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 13:44:25 -0500 Subject: [PATCH 047/105] Update Dockerfile --- docker/Dockerfile | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 76917cba..56dae23b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,19 +12,31 @@ RUN mvn clean package -DskipTests # Runtime stage FROM eclipse-temurin:8-jre-jammy -RUN rm -rf /var/lib/apt/lists/* -RUN apt-get update && apt-get install -y wget && apt-get clean WORKDIR /app -# Download AWS RDS CA bundle -RUN wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \ - && keytool -import -alias aws-rds-global -file global-bundle.pem -keystore $JAVA_HOME/lib/security/cacerts -storepass changeit -noprompt \ - && rm global-bundle.pem +# Install wget and clean up in single layer +RUN apt-get update && \ + apt-get install -y wget ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Download and import AWS RDS CA bundle for DocumentDB +RUN wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -O /tmp/rds-combined-ca-bundle.pem && \ + keytool -import \ + -trustcacerts \ + -alias aws-rds-ca-bundle \ + -file /tmp/rds-combined-ca-bundle.pem \ + -keystore ${JAVA_HOME}/lib/security/cacerts \ + -storepass changeit \ + -noprompt && \ + rm /tmp/rds-combined-ca-bundle.pem + +# Verify the certificate was imported +RUN keytool -list -keystore ${JAVA_HOME}/lib/security/cacerts -storepass changeit -alias aws-rds-ca-bundle COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar ENV SERVER_PORT=3001 EXPOSE 3001 -ENTRYPOINT ["java","-jar","app.jar"] +ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file From 1e6c0a899a13276f38cd598e45ec4a9ead027660 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 13:57:19 -0500 Subject: [PATCH 048/105] fix: use aws docs to configure cert --- docker/Dockerfile | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 56dae23b..b8cb6e76 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,28 +15,37 @@ FROM eclipse-temurin:8-jre-jammy WORKDIR /app -# Install wget and clean up in single layer +# Install required tools RUN apt-get update && \ - apt-get install -y wget ca-certificates && \ + apt-get install -y wget ca-certificates openssl perl && \ rm -rf /var/lib/apt/lists/* -# Download and import AWS RDS CA bundle for DocumentDB -RUN wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -O /tmp/rds-combined-ca-bundle.pem && \ - keytool -import \ - -trustcacerts \ - -alias aws-rds-ca-bundle \ - -file /tmp/rds-combined-ca-bundle.pem \ - -keystore ${JAVA_HOME}/lib/security/cacerts \ - -storepass changeit \ - -noprompt && \ - rm /tmp/rds-combined-ca-bundle.pem - -# Verify the certificate was imported -RUN keytool -list -keystore ${JAVA_HOME}/lib/security/cacerts -storepass changeit -alias aws-rds-ca-bundle +# Create truststore directory +RUN mkdir -p /app/certs + +# Download and create truststore with AWS DocumentDB certificates +# Following AWS official documentation: https://docs.aws.amazon.com/documentdb/latest/developerguide/connect_programmatically.html +RUN cd /app/certs && \ + wget -O global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem && \ + awk 'split_after == 1 {n++;split_after=0} /-----END CERTIFICATE-----/ {split_after=1}{print > "rds-ca-" n ".pem"}' < global-bundle.pem && \ + for CERT in rds-ca-*.pem; do \ + alias=$(openssl x509 -noout -text -in $CERT | perl -ne 'next unless /Subject:/; s/.*(CN=|CN = )//; print'); \ + echo "Importing $alias"; \ + keytool -import -file ${CERT} -alias "${alias}" -storepass changeit -keystore rds-truststore.jks -noprompt; \ + rm $CERT; \ + done && \ + rm global-bundle.pem + +# Verify truststore contents +RUN keytool -list -keystore /app/certs/rds-truststore.jks -storepass changeit COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar ENV SERVER_PORT=3001 EXPOSE 3001 -ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file +# Set JVM system properties to use the custom truststore +ENTRYPOINT ["java", \ + "-Djavax.net.ssl.trustStore=/app/certs/rds-truststore.jks", \ + "-Djavax.net.ssl.trustStorePassword=changeit", \ + "-jar", "app.jar"] \ No newline at end of file From 051892a5275df4df4c59d6bab328c46a6a45b902 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 14:08:34 -0500 Subject: [PATCH 049/105] fix: remove bintray dependency --- pom.xml | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/pom.xml b/pom.xml index 0b6ccb46..f8547930 100644 --- a/pom.xml +++ b/pom.xml @@ -19,27 +19,8 @@ 1.8 - - - - false - - bintray-gov-uk-notify-maven - bintray - https://dl.bintray.com/gov-uk-notify/maven - - - - - - - false - - bintray-gov-uk-notify-maven - bintray-plugins - https://dl.bintray.com/gov-uk-notify/maven - - + From f35741d5b6db1a67ddb49a432028e03212ed33bb Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 14:26:36 -0500 Subject: [PATCH 050/105] fix: remove unused python widget --- .../java/ca/gc/tbs/config/PageConfig.java | 8 ----- .../gc/tbs/controller/ReportController.java | 32 +------------------ 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/main/java/ca/gc/tbs/config/PageConfig.java b/src/main/java/ca/gc/tbs/config/PageConfig.java index 8890413d..5c103491 100644 --- a/src/main/java/ca/gc/tbs/config/PageConfig.java +++ b/src/main/java/ca/gc/tbs/config/PageConfig.java @@ -17,9 +17,6 @@ @Configuration public class PageConfig implements WebMvcConfigurer { - @Value("${pagesuccess.pythonScriptPath}") - private String pythonScriptPath; - @Bean public BCryptPasswordEncoder passwordEncoder() { BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); @@ -47,10 +44,5 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry .addResourceHandler("/templates/**") // « /templates/style.css .addResourceLocations("classpath:/templates/static/"); - - // File located on disk - registry - .addResourceHandler("/python/**") - .addResourceLocations("file://" + this.pythonScriptPath); } } diff --git a/src/main/java/ca/gc/tbs/controller/ReportController.java b/src/main/java/ca/gc/tbs/controller/ReportController.java index 40915d5a..a7c4d7cb 100644 --- a/src/main/java/ca/gc/tbs/controller/ReportController.java +++ b/src/main/java/ca/gc/tbs/controller/ReportController.java @@ -22,23 +22,14 @@ public class ReportController { @Autowired ProblemRepository problemRepository; - @Value("${pagesuccess.pythonPath}") - private String pythonPath; - - @Value("${pagesuccess.pythonScriptPath}") - private String pythonScriptPath; - public static String INPUT_FILENAME = "page_success_may_24.csv"; - public static String PYTHON_SCRIPT = "page_success_widget.py"; - public ReportController() {} @GetMapping("/reports") public View generateReports() throws Exception { - new File(Paths.get(pythonPath + "/" + INPUT_FILENAME).toString()).delete(); BufferedWriter writer = - Files.newBufferedWriter(Paths.get(pythonScriptPath + "/" + INPUT_FILENAME)); + Files.newBufferedWriter(Paths.get("/tmp/" + INPUT_FILENAME)); try (CSVPrinter csvPrinter = new CSVPrinter( @@ -74,8 +65,6 @@ public View generateReports() throws Exception { } csvPrinter.flush(); } - // call python - this.executePython(); return new RedirectView("/reports/view"); } @@ -84,23 +73,4 @@ public View generateReports() throws Exception { public String viewReports() { return "reports"; } - - public int executePython() throws Exception { - File pathToExecutable = new File(this.pythonPath); - ProcessBuilder builder = new ProcessBuilder(pathToExecutable.getAbsolutePath(), PYTHON_SCRIPT); - builder.directory(new File(this.pythonScriptPath).getAbsoluteFile()); - builder.redirectErrorStream(true); - Process process = builder.start(); - - Scanner s = new Scanner(process.getInputStream()); - StringBuilder text = new StringBuilder(); - while (s.hasNextLine()) { - text.append(s.nextLine()); - text.append("\n"); - } - System.out.println(text); - s.close(); - - return process.waitFor(); - } } From 1630412ddbd34814d3f1542de4e5875986372fd4 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 14:33:59 -0500 Subject: [PATCH 051/105] fix: remove unused email notify service (replace with CDS notify at some point) --- pom.xml | 11 --- .../ca/gc/tbs/controller/LoginController.java | 4 - .../java/ca/gc/tbs/service/EmailService.java | 80 ------------------- 3 files changed, 95 deletions(-) delete mode 100644 src/main/java/ca/gc/tbs/service/EmailService.java diff --git a/pom.xml b/pom.xml index f8547930..4fb62bda 100644 --- a/pom.xml +++ b/pom.xml @@ -101,17 +101,6 @@ commons-csv 1.8 - - uk.gov.service.notify - notifications-java-client - 3.15.1-RELEASE - pom - - - uk.gov.service.notify - notifications-java-client - 3.15.1-RELEASE - org.springframework.boot spring-boot-starter-thymeleaf diff --git a/src/main/java/ca/gc/tbs/controller/LoginController.java b/src/main/java/ca/gc/tbs/controller/LoginController.java index a731c734..fa301908 100644 --- a/src/main/java/ca/gc/tbs/controller/LoginController.java +++ b/src/main/java/ca/gc/tbs/controller/LoginController.java @@ -1,7 +1,6 @@ package ca.gc.tbs.controller; import ca.gc.tbs.domain.User; -import ca.gc.tbs.service.EmailService; import ca.gc.tbs.service.UserService; import java.text.SimpleDateFormat; import javax.servlet.http.HttpServletRequest; @@ -23,8 +22,6 @@ public class LoginController { @Autowired private UserService userService; - @Autowired private EmailService emailService; - @RequestMapping(value = "/login", method = RequestMethod.GET) public ModelAndView login(HttpServletRequest request) throws Exception { ModelAndView modelAndView = new ModelAndView(); @@ -59,7 +56,6 @@ public ModelAndView signup(HttpServletRequest request) { @RequestMapping(value = "/signup", method = RequestMethod.POST) public RedirectView createNewUser(@Valid User user, RedirectAttributes atts) { userService.saveUser(user); - emailService.sendUserActivationRequestEmail(user.getEmail()); atts.addFlashAttribute( "successMessage", "User has been registered successfully. You will be notified when the account has been" diff --git a/src/main/java/ca/gc/tbs/service/EmailService.java b/src/main/java/ca/gc/tbs/service/EmailService.java deleted file mode 100644 index 6fc59b69..00000000 --- a/src/main/java/ca/gc/tbs/service/EmailService.java +++ /dev/null @@ -1,80 +0,0 @@ -package ca.gc.tbs.service; - -import ca.gc.tbs.domain.User; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import uk.gov.service.notify.NotificationClient; - -@Service -public class EmailService { - - @Value("${notify.templateid.accountenabled}") - private String userActivationRequestKey; - - @Value("${notify.templateid.useractivationrequest}") - private String accountEnabledKey; - - @Value("${pagesuccess.loginURL}") - private String loginURL; - - @Autowired private UserService userService; - - public String getUserActivationRequestKey() { - return userActivationRequestKey; - } - - public void setUserActivationRequestKey(String userActivationRequestKey) { - this.userActivationRequestKey = userActivationRequestKey; - } - - public String getAccountEnabledKey() { - return accountEnabledKey; - } - - public void setAccountEnabledKey(String accountEnabledKey) { - this.accountEnabledKey = accountEnabledKey; - } - - public NotificationClient getNotificationClient() { - return new NotificationClient(getAPIKey(), "https://api.notification.alpha.canada.ca"); - } - - private String getAPIKey() { - try { - File file = - new File( - getClass() - .getClassLoader() - .getResource("static/secrets/notification.secret") - .getFile()); - return new String( - Files.readAllBytes(Paths.get(file.getCanonicalPath())), StandardCharsets.UTF_8); - } catch (Exception e) { - - } - return ""; - } - - public void sendUserActivationRequestEmail(String email) { - Map personalisation = new HashMap<>(); - personalisation.put("email", email); - personalisation.put("loginURL", loginURL); - List admins = this.userService.findUserByRole(UserService.ADMIN_ROLE); - for (User user : admins) { - try { - this.getNotificationClient() - .sendEmail(this.userActivationRequestKey, user.getEmail(), personalisation, ""); - } catch (Exception e) { - System.out.println(e.getMessage()); - } - } - } -} From 2d40d9cc84dd4d660a0025241477d9b04ad9e973 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 15:38:20 -0500 Subject: [PATCH 052/105] fix: remove calling the google doc. --- src/main/java/ca/gc/tbs/service/BadWords.java | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/main/java/ca/gc/tbs/service/BadWords.java b/src/main/java/ca/gc/tbs/service/BadWords.java index e2fa7d5b..7686303d 100644 --- a/src/main/java/ca/gc/tbs/service/BadWords.java +++ b/src/main/java/ca/gc/tbs/service/BadWords.java @@ -1,13 +1,9 @@ package ca.gc.tbs.service; import java.io.BufferedReader; -import java.io.IOException; import java.io.InputStreamReader; -import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -32,8 +28,6 @@ public static void loadConfigs() { for (String file : DEFAULT_FILES) { loadFileConfigs(file); } - loadGoogleConfigs( - "https://docs.google.com/spreadsheets/d/1hIEi2YG3ydav1E06Bzf2mQbGZ12kh2fe4ISgLg_UBuM/export?format=csv"); loadAllowedWords(ALLOWED_WORDS_FILE); logger.info("Loaded {} words to filter out", words.size()); logger.info("Loaded {} allowed words that will not be filtered", allowedWords.size()); @@ -69,19 +63,6 @@ private static void loadFileConfigs(String filePath) { } } - private static void loadGoogleConfigs(String googleSheetUrl) { - try (BufferedReader reader = - new BufferedReader( - new InputStreamReader(new URL(googleSheetUrl).openConnection().getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - words.add(line.trim().split(",")[0].toLowerCase()); // assuming first column has the word - } - } catch (IOException e) { - logger.error("Error loading Google config from {}", googleSheetUrl, e); - } - } - /** * Returns the set of allowed words that should not be redacted. * This is used by other services that need to know which words to exclude from redaction. From e7f98362e20b109c0d2a4a462349a5898eee51f4 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 15:51:59 -0500 Subject: [PATCH 053/105] feat: add health check endpoint for ecs --- pom.xml | 6 ++++++ .../java/ca/gc/tbs/config/WebSecurityConfig.java | 2 +- terragrunt/aws/ecs/ecs.tf | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4fb62bda..fd81769c 100644 --- a/pom.xml +++ b/pom.xml @@ -147,6 +147,12 @@ org.springframework.boot spring-boot-starter-cache + + + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java b/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java index 978b7186..0f2b1dca 100644 --- a/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java +++ b/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java @@ -46,7 +46,7 @@ protected void configure(HttpSecurity http) throws Exception { .permitAll() .antMatchers("/api/user/**") .hasRole("USER") - .antMatchers("/", "/checkExists", "/error", "/enableAdmin", "/login", "/signup", "/success") + .antMatchers("/", "/checkExists", "/error", "/enableAdmin", "/login", "/signup", "/success", "/health", "/actuator/health") .permitAll() .antMatchers("/u/**") .hasAnyAuthority("ADMIN") diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index 5e5df177..a87b2c08 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -47,6 +47,18 @@ module "feedback_viewer" { { name = "SPRING_DATA_MONGODB_URI" value = "mongodb://$${SPRING_DATA_MONGODB_USERNAME}:$${SPRING_DATA_MONGODB_PASSWORD}@${var.docdb_endpoint}:27017/pagesuccess?ssl=true&retryWrites=false&tlsAllowInvalidHostnames=true" + }, + { + name = "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE" + value = "health" + }, + { + name = "MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS" + value = "always" + }, + { + name = "MANAGEMENT_HEALTH_DEFAULTS_ENABLED" + value = "true" } ] container_linux_parameters = {} From 31f2ef30aa300da5c922dbd8701ea67fff3815f9 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 15:52:32 -0500 Subject: [PATCH 054/105] fix: fmt --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index fd81769c..b5c01428 100644 --- a/pom.xml +++ b/pom.xml @@ -147,7 +147,7 @@ org.springframework.boot spring-boot-starter-cache - + org.springframework.boot From be644e69b776008778fcb1f28faf4ac9b25abef5 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 1 Dec 2025 15:57:37 -0500 Subject: [PATCH 055/105] fix: add base path --- terragrunt/aws/ecs/ecs.tf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index a87b2c08..962ce3e3 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -52,6 +52,10 @@ module "feedback_viewer" { name = "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE" value = "health" }, + { + name = "MANAGEMENT_ENDPOINTS_WEB_BASE_PATH" + value = "/" + }, { name = "MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS" value = "always" From 80ddb51d60a9c13d952313c0638c63535202e767 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Tue, 2 Dec 2025 11:54:59 -0500 Subject: [PATCH 056/105] fix: enable server forward header --- terragrunt/aws/ecs/ecs.tf | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index 962ce3e3..31c7560e 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -48,6 +48,10 @@ module "feedback_viewer" { name = "SPRING_DATA_MONGODB_URI" value = "mongodb://$${SPRING_DATA_MONGODB_USERNAME}:$${SPRING_DATA_MONGODB_PASSWORD}@${var.docdb_endpoint}:27017/pagesuccess?ssl=true&retryWrites=false&tlsAllowInvalidHostnames=true" }, + { + name = "SERVER_FORWARD_HEADERS_STRATEGY" + value = "FRAMEWORK" + }, { name = "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE" value = "health" @@ -107,4 +111,4 @@ resource "aws_cloudwatch_log_group" "feedback-viewer_group" { resource "aws_cloudwatch_log_stream" "feedback-viewer_stream" { name = "${var.product_name}-log-stream" log_group_name = aws_cloudwatch_log_group.feedback-viewer_group.name -} \ No newline at end of file +} From bf6853d64750cb8f07d346eed7f8c6d1c08bdf13 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Tue, 2 Dec 2025 12:29:36 -0500 Subject: [PATCH 057/105] fix: remove redundant rule --- terragrunt/aws/ecs/security_group.tf | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/terragrunt/aws/ecs/security_group.tf b/terragrunt/aws/ecs/security_group.tf index 5acb2848..013dbe18 100644 --- a/terragrunt/aws/ecs/security_group.tf +++ b/terragrunt/aws/ecs/security_group.tf @@ -33,18 +33,4 @@ resource "aws_security_group_rule" "ecs_egress_all" { cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.ecs_tasks.id -} - -resource "aws_security_group_rule" "ecs_egress_docdb" { - description = "Allow ECS security group to send traffic to DocumentDB" - type = "egress" - from_port = 27017 - to_port = 27017 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - security_group_id = aws_security_group.ecs_tasks.id -} - -### -# Traffic to DocumentDB should only come from ECS -### \ No newline at end of file +} \ No newline at end of file From 36f3fd76e02db231c78b105dfd2a85c67a377c9e Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 4 Dec 2025 15:43:59 -0500 Subject: [PATCH 058/105] fix: update versions to resolve security vulnerabilities. --- pom.xml | 88 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/pom.xml b/pom.xml index b5c01428..bdd0178a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,8 @@ org.springframework.boot spring-boot-starter-parent - 2.2.1.RELEASE + + 2.2.13.RELEASE ca.gc.tbs @@ -17,10 +18,26 @@ 1.8 - - + + + 2.17.1 + + + 5.2.25.RELEASE + + + 9.0.83 + + + 2.0 + + + 5.2.15.RELEASE + + + 2.2.13.RELEASE + @@ -29,21 +46,7 @@ 1.0.3 - - org.springframework.boot - spring-boot-starter-data-jpa - - - com.h2database - h2 - runtime - - - - net.sourceforge.nekohtml - nekohtml - 1.9.21 - + org.springframework.boot spring-boot-starter @@ -55,27 +58,19 @@ org.apache.poi poi - 4.1.2 + 5.2.3 org.apache.poi poi-ooxml - 4.1.2 + 5.2.3 org.springframework.boot spring-boot-starter-security - - org.dom4j - dom4j - 2.1.3 - - - jaxen - jaxen - + nz.net.ultraq.thymeleaf thymeleaf-layout-dialect @@ -99,7 +94,7 @@ org.apache.commons commons-csv - 1.8 + 1.10.0 org.springframework.boot @@ -124,23 +119,23 @@ com.auth0 java-jwt - 3.18.2 + 4.4.0 io.jsonwebtoken jjwt-api - 0.10.7 + 0.11.5 io.jsonwebtoken jjwt-impl - 0.10.7 + 0.11.5 runtime io.jsonwebtoken jjwt-jackson - 0.10.7 + 0.11.5 runtime @@ -158,13 +153,13 @@ edu.stanford.nlp stanford-corenlp - 4.4.0 + 4.5.5 edu.stanford.nlp stanford-corenlp - 4.4.0 + 4.5.5 models @@ -173,16 +168,31 @@ org.springframework.boot spring-boot-starter-logging + + + + org.springframework.boot + spring-boot-starter-logging + + + + + + org.springframework.boot + spring-boot-starter-log4j2 + + + org.apache.logging.log4j log4j-api - compile + ${log4j2.version} org.apache.logging.log4j log4j-core - compile + ${log4j2.version} From 411a76a2d8e9a4e935a67efa4a7497924cad2085 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 4 Dec 2025 15:46:01 -0500 Subject: [PATCH 059/105] fix: update apache version 5.3 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index bdd0178a..70a096d8 100644 --- a/pom.xml +++ b/pom.xml @@ -58,12 +58,12 @@ org.apache.poi poi - 5.2.3 + 5.3.0 org.apache.poi poi-ooxml - 5.2.3 + 5.3.0 From f18cff3085e31472beb9a686272c72e95927531d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 4 Dec 2025 15:47:19 -0500 Subject: [PATCH 060/105] fix: update version 5.4 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 70a096d8..a8462c04 100644 --- a/pom.xml +++ b/pom.xml @@ -58,12 +58,12 @@ org.apache.poi poi - 5.3.0 + 5.4.0 org.apache.poi poi-ooxml - 5.3.0 + 5.4.0 From a9f65d8ec868b1650fd96a44fa00329f36ddc265 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 8 Dec 2025 11:57:56 -0500 Subject: [PATCH 061/105] feat: add GC IP validation filter with RDAP integration --- .../java/ca/gc/tbs/filter/GcIpFilter.java | 220 ++++++++++++++++++ .../gc/tbs/service/GcIpValidationService.java | 117 ++++++++++ 2 files changed, 337 insertions(+) create mode 100644 src/main/java/ca/gc/tbs/filter/GcIpFilter.java create mode 100644 src/main/java/ca/gc/tbs/service/GcIpValidationService.java diff --git a/src/main/java/ca/gc/tbs/filter/GcIpFilter.java b/src/main/java/ca/gc/tbs/filter/GcIpFilter.java new file mode 100644 index 00000000..4a7b6625 --- /dev/null +++ b/src/main/java/ca/gc/tbs/filter/GcIpFilter.java @@ -0,0 +1,220 @@ +package ca.gc.tbs.filter; + +import ca.gc.tbs.service.GcIpValidationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +/** + * Filter to restrict access to Government of Canada IP addresses only + * This filter runs before authentication to ensure only GC networks can access the application + */ +@Component +@Order(1) // Run before other filters +public class GcIpFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(GcIpFilter.class); + + @Autowired + private GcIpValidationService gcIpValidationService; + + @Value("${gc.ip.filter.enabled:true}") + private boolean filterEnabled; + + @Value("${gc.ip.filter.whitelist:}") + private String whitelistIps; + + @Value("${gc.ip.filter.whitelist.file:}") + private String whitelistFilePath; + + private Set fileWhitelistIps = new HashSet<>(); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + // Skip filter if disabled (for local development) + if (!filterEnabled) { + logger.debug("GC IP filter is disabled"); + chain.doFilter(request, response); + return; + } + + String clientIp = getClientIpAddress(httpRequest); + logger.debug("Request from IP: {}", clientIp); + + // Check if IP is in whitelist + if (isWhitelisted(clientIp)) { + logger.debug("IP {} is authorized (whitelisted)", clientIp); + chain.doFilter(request, response); + return; + } + + // Check if IP is owned by GC + if (gcIpValidationService.isGcIp(clientIp)) { + logger.debug("IP {} is authorized (GC-owned)", clientIp); + chain.doFilter(request, response); + } else { + logger.warn("Access denied for non-GC IP: {}", clientIp); + httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); + httpResponse.setContentType("text/html; charset=UTF-8"); + httpResponse.getWriter().write( + "" + + "" + + "" + + " " + + " " + + " Access Denied" + + " " + + "" + + "" + + "

Access Denied

" + + "

This application is only accessible from Government of Canada networks.

" + + "

If you believe you should have access, please contact your system administrator.

" + + "" + + "" + ); + } + } + + /** + * Extract client IP address from request + * Handles X-Forwarded-For header for requests behind load balancer + */ + private String getClientIpAddress(HttpServletRequest request) { + String[] headerCandidates = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + }; + + for (String header : headerCandidates) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + // X-Forwarded-For can contain multiple IPs, take the first one + if (ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + } + + return request.getRemoteAddr(); + } + + /** + * Check if IP is in the whitelist (property or file-based) + */ + private boolean isWhitelisted(String ip) { + // Check property-based whitelist + if (whitelistIps != null && !whitelistIps.trim().isEmpty()) { + String[] whitelist = whitelistIps.split(","); + for (String whitelistedIp : whitelist) { + if (whitelistedIp.trim().equals(ip)) { + return true; + } + } + } + + // Check file-based whitelist + if (!fileWhitelistIps.isEmpty() && fileWhitelistIps.contains(ip)) { + return true; + } + + return false; + } + + /** + * Load whitelist IPs from file + * Supports comments (lines starting with #) + * Supports both comma-separated and newline-separated formats + */ + private void loadWhitelistFromFile() { + if (whitelistFilePath == null || whitelistFilePath.trim().isEmpty()) { + return; + } + + File file = new File(whitelistFilePath); + if (!file.exists()) { + logger.warn("Whitelist file not found: {}", whitelistFilePath); + return; + } + + fileWhitelistIps.clear(); + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + line = line.trim(); + + // Skip empty lines and comments + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + // Handle comma-separated IPs + if (line.contains(",")) { + String[] ips = line.split(","); + for (String ip : ips) { + String trimmedIp = ip.trim(); + if (!trimmedIp.isEmpty()) { + fileWhitelistIps.add(trimmedIp); + } + } + } else { + // Single IP per line + fileWhitelistIps.add(line); + } + } + logger.info("Loaded {} IP addresses from whitelist file: {}", + fileWhitelistIps.size(), whitelistFilePath); + } catch (IOException e) { + logger.error("Error reading whitelist file {}: {}", whitelistFilePath, e.getMessage()); + } + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // Load IPs from file if configured + loadWhitelistFromFile(); + + logger.info("GC IP Filter initialized - filter enabled: {}, property whitelist: {}, file whitelist: {} IPs", + filterEnabled, + whitelistIps != null && !whitelistIps.isEmpty() ? "configured" : "none", + fileWhitelistIps.size()); + } + + @Override + public void destroy() { + logger.info("GC IP Filter destroyed"); + } +} diff --git a/src/main/java/ca/gc/tbs/service/GcIpValidationService.java b/src/main/java/ca/gc/tbs/service/GcIpValidationService.java new file mode 100644 index 00000000..76c050b0 --- /dev/null +++ b/src/main/java/ca/gc/tbs/service/GcIpValidationService.java @@ -0,0 +1,117 @@ +package ca.gc.tbs.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * Service to validate if an IP address is owned by the Government of Canada + * Uses RDAP (Registration Data Access Protocol) API to check IP ownership + */ +@Service +public class GcIpValidationService { + + private static final Logger logger = LoggerFactory.getLogger(GcIpValidationService.class); + private static final String RDAP_API_URL = "https://rdap.arin.net/registry/ip/"; + private static final String GC_REGISTRANT_HANDLE = "SSC-299"; // Shared Services Canada handle + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public GcIpValidationService() { + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + } + + /** + * Check if an IP address is owned by the Government of Canada + * Results are cached for 24 hours to avoid excessive RDAP API calls + * + * @param ipAddress The IP address to check + * @return true if the IP is owned by GC, false otherwise + */ + @Cacheable(value = "gcIpCache", key = "#ipAddress") + public boolean isGcIp(String ipAddress) { + if (ipAddress == null || ipAddress.isEmpty()) { + logger.warn("Received null or empty IP address"); + return false; + } + + try { + logger.debug("Checking if IP {} is owned by GC", ipAddress); + + String url = RDAP_API_URL + ipAddress; + String response = restTemplate.getForObject(url, String.class); + + if (response == null) { + logger.warn("Received null response from RDAP API for IP {}", ipAddress); + return false; + } + + JsonNode root = objectMapper.readTree(response); + JsonNode entities = root.get("entities"); + + if (entities != null && entities.isArray()) { + boolean isGc = recursiveEntitySearch(entities); + logger.info("IP {} is {} owned by GC", ipAddress, isGc ? "" : "NOT"); + return isGc; + } + + logger.warn("No entities found in RDAP response for IP {}", ipAddress); + return false; + + } catch (Exception e) { + logger.error("Error checking IP {} ownership: {}", ipAddress, e.getMessage()); + // Fail closed - if we can't verify, block access + return false; + } + } + + /** + * Recursively search through entity records to find GC registrant + * + * @param entities JsonNode array of entities + * @return true if SSC-299 (Shared Services Canada) is found in registrants + */ + private boolean recursiveEntitySearch(JsonNode entities) { + if (entities == null || !entities.isArray()) { + return false; + } + + for (JsonNode entity : entities) { + // Check if this entity is a registrant + JsonNode roles = entity.get("roles"); + if (roles != null && roles.isArray()) { + boolean isRegistrant = false; + for (JsonNode role : roles) { + if ("registrant".equals(role.asText())) { + isRegistrant = true; + break; + } + } + + // If this is a registrant, check if it's SSC-299 + if (isRegistrant) { + JsonNode handle = entity.get("handle"); + if (handle != null && GC_REGISTRANT_HANDLE.equals(handle.asText())) { + return true; + } + } + } + + // Recursively check nested entities + JsonNode nestedEntities = entity.get("entities"); + if (nestedEntities != null && recursiveEntitySearch(nestedEntities)) { + return true; + } + } + + return false; + } +} From 339bbaaf0781c53447bb350573cde3151e526ec8 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 8 Dec 2025 11:58:24 -0500 Subject: [PATCH 062/105] chore: add GC IP cache and filter configuration --- src/main/java/ca/gc/tbs/config/CacheConfig.java | 2 +- src/main/java/ca/gc/tbs/service/ProblemCacheService.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/ca/gc/tbs/config/CacheConfig.java b/src/main/java/ca/gc/tbs/config/CacheConfig.java index 4ece8635..c378fd60 100644 --- a/src/main/java/ca/gc/tbs/config/CacheConfig.java +++ b/src/main/java/ca/gc/tbs/config/CacheConfig.java @@ -14,6 +14,6 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { - return new ConcurrentMapCacheManager("problemDates", "distinctUrls", "processedProblems"); + return new ConcurrentMapCacheManager("problemDates", "distinctUrls", "processedProblems", "gcIpCache"); } } diff --git a/src/main/java/ca/gc/tbs/service/ProblemCacheService.java b/src/main/java/ca/gc/tbs/service/ProblemCacheService.java index bf02bbab..3bdaf4ab 100644 --- a/src/main/java/ca/gc/tbs/service/ProblemCacheService.java +++ b/src/main/java/ca/gc/tbs/service/ProblemCacheService.java @@ -18,9 +18,9 @@ public class ProblemCacheService { @Autowired private ProblemRepository problemRepository; @Scheduled(cron = "0 0 0 * * *") - @CacheEvict(value = {"distinctUrls", "processedProblems"}, allEntries = true) + @CacheEvict(value = {"distinctUrls", "processedProblems", "gcIpCache"}, allEntries = true) public void clearCacheDaily() { - LOGGER.info("Evicting all caches at midnight"); + LOGGER.info("Evicting all caches at midnight: distinctUrls, processedProblems, gcIpCache"); } @Cacheable("processedProblems") From f4cac54228ee82bff5c7f321575c32bffaa7a7d8 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 8 Dec 2025 11:58:47 -0500 Subject: [PATCH 063/105] feat: add encrypted IP whitelist file support --- .gitignore | 3 +++ secrets/gc_ip_whitelist.txt.gpg | Bin 0 -> 278 bytes 2 files changed, 3 insertions(+) create mode 100644 secrets/gc_ip_whitelist.txt.gpg diff --git a/.gitignore b/.gitignore index 4414ad16..96ea9d42 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ buildNumber.properties **/application.properties **/application-local.properties +# Secrets - never commit unencrypted files +secrets/gc_ip_whitelist.txt + # Docker environment files docker/.env .env diff --git a/secrets/gc_ip_whitelist.txt.gpg b/secrets/gc_ip_whitelist.txt.gpg new file mode 100644 index 0000000000000000000000000000000000000000..b18d6878098dd8b97a818dc31ac73b6773236f05 GIT binary patch literal 278 zcmV+x0qOpX4Fm}T2(Yi2wKDLq0`$_rL;)>FZQ6}k?`6f&Z9aG1aNh}ox;!3b7cC@( zhOysED2ztVIkWeUAinW4c}WTCp5RI{c7GrmiC`(^J5gFPhV|N^LJu%cES?IPmDn(N z%|@6Px=~&0656bCWyKxqo{64MroMb7)t2c+bKlX``Ljkm%KPKFf3YxvHMz~PWmW4d z<6nV*Z&&4y6%hC#W(EK-8=O|JKx(ax2}w^gvB+NPKd<{A6mtEW3LBV&)8~4@@^uJq zw!w$)h1JU5IZ%B=2(z?dMF-y+6NAH%(53lKvEnME?N3>ihg8zDuk1N$92t7PScAOo cN@^h+-K^FJjin1L)wh?hLRCE3inNr)f#ghyX#fBK literal 0 HcmV?d00001 From 20e9cb3378775d8c414b6d320f115bc5b23e0827 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 8 Dec 2025 11:58:52 -0500 Subject: [PATCH 064/105] ci: decrypt IP whitelist during Docker build --- .github/workflows/build_and_deploy_staging.yml | 7 +++++++ docker/Dockerfile | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_deploy_staging.yml b/.github/workflows/build_and_deploy_staging.yml index fae86f09..76ef3774 100644 --- a/.github/workflows/build_and_deploy_staging.yml +++ b/.github/workflows/build_and_deploy_staging.yml @@ -40,6 +40,13 @@ jobs: id: login-ecr uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + - name: Decrypt IP whitelist + run: | + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --batch --yes --quiet \ + --passphrase-fd 0 --decrypt \ + --output secrets/gc_ip_whitelist.txt \ + secrets/gc_ip_whitelist.txt.gpg + - name: Build container run: | docker build \ diff --git a/docker/Dockerfile b/docker/Dockerfile index b8cb6e76..06d23be4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,8 +6,13 @@ WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B -# Copy source and build +# Copy source code COPY src ./src + +# Copy decrypted whitelist file (decrypted in GitHub Actions) +COPY secrets/gc_ip_whitelist.txt ./secrets/ + +# Build the application RUN mvn clean package -DskipTests # Runtime stage @@ -39,13 +44,19 @@ RUN cd /app/certs && \ # Verify truststore contents RUN keytool -list -keystore /app/certs/rds-truststore.jks -storepass changeit +# Create config directory for whitelist file +RUN mkdir -p /app/config + +# Copy application JAR and decrypted whitelist file from build stage COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar +COPY --from=build /build/secrets/gc_ip_whitelist.txt /app/config/gc_ip_whitelist.txt ENV SERVER_PORT=3001 +ENV GC_IP_FILTER_WHITELIST_FILE=/app/config/gc_ip_whitelist.txt EXPOSE 3001 # Set JVM system properties to use the custom truststore ENTRYPOINT ["java", \ "-Djavax.net.ssl.trustStore=/app/certs/rds-truststore.jks", \ "-Djavax.net.ssl.trustStorePassword=changeit", \ - "-jar", "app.jar"] \ No newline at end of file + "-jar", "app.jar"] From 03155eea523007d56d4c045756f6d931f9359b6c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 8 Dec 2025 11:59:12 -0500 Subject: [PATCH 065/105] chore: add IP whitelist encryption scripts --- scripts/decrypt_local.sh | 5 ++++ scripts/decrypt_whitelist.sh | 41 +++++++++++++++++++++++++++++++++ scripts/encrypt_whitelist.sh | 44 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100755 scripts/decrypt_local.sh create mode 100755 scripts/decrypt_whitelist.sh create mode 100755 scripts/encrypt_whitelist.sh diff --git a/scripts/decrypt_local.sh b/scripts/decrypt_local.sh new file mode 100755 index 00000000..7ac4401f --- /dev/null +++ b/scripts/decrypt_local.sh @@ -0,0 +1,5 @@ +#!/bin/sh +export GPG_TTY=$(tty) +gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ +--output ./src/main/resources/application.properties ./src/main/resources/application.properties.gpg +ls ./src/main/resources \ No newline at end of file diff --git a/scripts/decrypt_whitelist.sh b/scripts/decrypt_whitelist.sh new file mode 100755 index 00000000..fb8529fc --- /dev/null +++ b/scripts/decrypt_whitelist.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Script to decrypt the IP whitelist file using GPG for local development +# Usage: ./scripts/decrypt_whitelist.sh [passphrase] + +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +ENCRYPTED_FILE="secrets/gc_ip_whitelist.txt.gpg" +OUTPUT_FILE="secrets/gc_ip_whitelist.txt" + +# Check if encrypted file exists +if [ ! -f "$ENCRYPTED_FILE" ]; then + echo -e "${RED}Error: Encrypted file $ENCRYPTED_FILE not found${NC}" + exit 1 +fi + +# Get passphrase from argument or prompt +if [ -z "$1" ]; then + echo -e "${YELLOW}Enter GPG passphrase:${NC}" + read -s PASSPHRASE + echo +else + PASSPHRASE="$1" +fi + +# Decrypt the file +echo -e "${YELLOW}Decrypting $ENCRYPTED_FILE...${NC}" +gpg --batch --yes --passphrase "$PASSPHRASE" --decrypt --output "$OUTPUT_FILE" "$ENCRYPTED_FILE" + +if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Successfully decrypted to $OUTPUT_FILE${NC}" + echo -e "${YELLOW}Remember: This file is excluded from git. Never commit the unencrypted version.${NC}" +else + echo -e "${RED}✗ Decryption failed${NC}" + exit 1 +fi diff --git a/scripts/encrypt_whitelist.sh b/scripts/encrypt_whitelist.sh new file mode 100755 index 00000000..fcc30f36 --- /dev/null +++ b/scripts/encrypt_whitelist.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Script to encrypt the IP whitelist file using GPG +# Usage: ./scripts/encrypt_whitelist.sh [passphrase] + +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +SOURCE_FILE="secrets/gc_ip_whitelist.txt" +ENCRYPTED_FILE="secrets/gc_ip_whitelist.txt.gpg" + +# Check if source file exists +if [ ! -f "$SOURCE_FILE" ]; then + echo -e "${RED}Error: Source file $SOURCE_FILE not found${NC}" + exit 1 +fi + +# Get passphrase from argument or prompt +if [ -z "$1" ]; then + echo -e "${YELLOW}Enter GPG passphrase:${NC}" + read -s PASSPHRASE + echo +else + PASSPHRASE="$1" +fi + +# Encrypt the file +echo -e "${YELLOW}Encrypting $SOURCE_FILE...${NC}" +gpg --batch --yes --passphrase "$PASSPHRASE" --symmetric --cipher-algo AES256 --output "$ENCRYPTED_FILE" "$SOURCE_FILE" + +if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Successfully encrypted to $ENCRYPTED_FILE${NC}" + echo -e "${YELLOW}Remember to:${NC}" + echo -e " 1. Add $ENCRYPTED_FILE to git" + echo -e " 2. Never commit the unencrypted $SOURCE_FILE" + echo -e " 3. Store the passphrase in GitHub Secrets as GPG_PASSPHRASE" +else + echo -e "${RED}✗ Encryption failed${NC}" + exit 1 +fi From a70b06de8f0252e2648b4e2794b2854edf8fcf3d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 8 Dec 2025 11:59:29 -0500 Subject: [PATCH 066/105] fix: delete decrypt.sh --- scripts/decrypt.sh | 8 -------- 1 file changed, 8 deletions(-) delete mode 100755 scripts/decrypt.sh diff --git a/scripts/decrypt.sh b/scripts/decrypt.sh deleted file mode 100755 index 2466ef0f..00000000 --- a/scripts/decrypt.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -export GPG_TTY=$(tty) -gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ ---output ./src/main/resources/application.properties ./src/main/resources/application.properties.gpg -ls ./src/main/resources -gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ ---output kubernetes/feedback-viewer-ingress.yml kubernetes/feedback-viewer-ingress.yml.gpg -ls kubernetes From c57f949c2f5447a9d0b5a68880a9302c63eea94e Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Tue, 9 Dec 2025 08:43:42 -0500 Subject: [PATCH 067/105] Update gc_ip_whitelist.txt.gpg --- secrets/gc_ip_whitelist.txt.gpg | Bin 278 -> 284 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/secrets/gc_ip_whitelist.txt.gpg b/secrets/gc_ip_whitelist.txt.gpg index b18d6878098dd8b97a818dc31ac73b6773236f05..e081fc14c1d9544d363f39ef3dad2f04a6a08638 100644 GIT binary patch literal 284 zcmV+%0ptFR4Fm}T2vJ1qE5^LeWKH-KS}$BCs^n^&t~0P{k6PaSp|KM(=&zx_WQ4fzcAggydBv8dVL4 zJ^f*qY|Bx$vhMZJ@g5Zcwa{g~?Aw&YQl!{yfDTkU={@WpUrkP|hWF&PisaUA^DU07ov(CmhFExKO?Mh-R5+9ocnM#sv7}sK-quDtVTI7yy7$7 zGOiIXUBpb~Ua}sp&)Ns%!dl?ZiBtbi%rNCAtKL3QDhP iS}p~s5lm7hr0@4Rbu5K6x85-nk-~{vO`+P;z}|*25{w=I literal 278 zcmV+x0qOpX4Fm}T2(Yi2wKDLq0`$_rL;)>FZQ6}k?`6f&Z9aG1aNh}ox;!3b7cC@( zhOysED2ztVIkWeUAinW4c}WTCp5RI{c7GrmiC`(^J5gFPhV|N^LJu%cES?IPmDn(N z%|@6Px=~&0656bCWyKxqo{64MroMb7)t2c+bKlX``Ljkm%KPKFf3YxvHMz~PWmW4d z<6nV*Z&&4y6%hC#W(EK-8=O|JKx(ax2}w^gvB+NPKd<{A6mtEW3LBV&)8~4@@^uJq zw!w$)h1JU5IZ%B=2(z?dMF-y+6NAH%(53lKvEnME?N3>ihg8zDuk1N$92t7PScAOo cN@^h+-K^FJjin1L)wh?hLRCE3inNr)f#ghyX#fBK From 762dbe050a653a66d6cf3c1ecdb3a8099f0d8bb2 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Tue, 9 Dec 2025 10:14:38 -0500 Subject: [PATCH 068/105] Update gc_ip_whitelist.txt.gpg --- secrets/gc_ip_whitelist.txt.gpg | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/secrets/gc_ip_whitelist.txt.gpg b/secrets/gc_ip_whitelist.txt.gpg index e081fc14..d2cfe24e 100644 --- a/secrets/gc_ip_whitelist.txt.gpg +++ b/secrets/gc_ip_whitelist.txt.gpg @@ -1,3 +1,5 @@ -  &`PJ72ݟ2V8 s3т~R`!1 -HYBEbݧi"4!+PqyF}޺zoĄU%U =alQe۔RlT;=_MNL~GXyn90!7?#qtzp6o@ȬF9032/]L^*Y٬zR!Xc Date: Tue, 9 Dec 2025 10:18:50 -0500 Subject: [PATCH 069/105] feat: exclude health check endpoint from being blocked --- src/main/java/ca/gc/tbs/filter/GcIpFilter.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/ca/gc/tbs/filter/GcIpFilter.java b/src/main/java/ca/gc/tbs/filter/GcIpFilter.java index 4a7b6625..9d7a375d 100644 --- a/src/main/java/ca/gc/tbs/filter/GcIpFilter.java +++ b/src/main/java/ca/gc/tbs/filter/GcIpFilter.java @@ -56,6 +56,14 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha return; } + // Skip filter for health check endpoints (ALB health checks) + String requestPath = httpRequest.getRequestURI(); + if ("/health".equals(requestPath) || "/actuator/health".equals(requestPath)) { + logger.debug("Skipping GC IP filter for health check endpoint: {}", requestPath); + chain.doFilter(request, response); + return; + } + String clientIp = getClientIpAddress(httpRequest); logger.debug("Request from IP: {}", clientIp); From 02cabfca1102c852b4a4d65360b1552b4effefd9 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 10:57:02 -0500 Subject: [PATCH 070/105] delete old infra --- .github/workflows/main.yml | 50 --------------------- kubernetes/feedback-viewer-deployment.yml | 35 --------------- kubernetes/feedback-viewer-ingress.yml.gpg | Bin 1550 -> 0 bytes kubernetes/feedback-viewer-service.yml | 13 ------ 4 files changed, 98 deletions(-) delete mode 100644 .github/workflows/main.yml delete mode 100644 kubernetes/feedback-viewer-deployment.yml delete mode 100644 kubernetes/feedback-viewer-ingress.yml.gpg delete mode 100644 kubernetes/feedback-viewer-service.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index f73a5580..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Deploy to AKS Cluster -on: - push: - branches: - - master -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: Decrypt large secret - run: ./scripts/decrypt.sh - env: - APPLICATION_PROPERTIES_PASSPHRASE: ${{ secrets.APPLICATION_PROPERTIES_PASSPHRASE }} - - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - - name: Build with Maven - run: mvn install --file pom.xml - - - uses: Azure/docker-login@v1 - with: - login-server: tbsacr.azurecr.io - username: ${{ secrets.ACR_USERNAME }} - password: ${{ secrets.ACR_PASSWORD }} - - - run: | - docker build -f ./docker/Dockerfile . -t tbsacr.azurecr.io/feedback-viewer:${{ github.sha }} - docker push tbsacr.azurecr.io/feedback-viewer:${{ github.sha }} - - # Set the target AKS cluster. - - uses: Azure/aks-set-context@v1 - with: - creds: "${{ secrets.AZURE_CREDENTIALS }}" - cluster-name: tbs-prod-aks - resource-group: tbs-prod-rg - - - uses: Azure/k8s-deploy@v1 - with: - manifests: | - kubernetes/feedback-viewer-deployment.yml - kubernetes/feedback-viewer-service.yml - kubernetes/feedback-viewer-ingress.yml - images: | - tbsacr.azurecr.io/feedback-viewer:${{ github.sha }} - namespace: | - pagesuccess diff --git a/kubernetes/feedback-viewer-deployment.yml b/kubernetes/feedback-viewer-deployment.yml deleted file mode 100644 index 8d781023..00000000 --- a/kubernetes/feedback-viewer-deployment.yml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: feedback-viewer - name: feedback-viewer -spec: - replicas: 1 - selector: - matchLabels: - app: feedback-viewer - template: - metadata: - labels: - app: feedback-viewer - spec: - containers: - - name: feedback-viewer - image: tbsacr.azurecr.io/feedback-viewer:1.0.0 - imagePullPolicy: Always - resources: - limits: - cpu: "2" - memory: 12Gi - requests: - cpu: 1000m - memory: 4Gi - livenessProbe: - httpGet: - path: /health # The endpoint in your application that indicates it is healthy - port: 8080 - initialDelaySeconds: 60 # Adjust based on your application startup time - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 diff --git a/kubernetes/feedback-viewer-ingress.yml.gpg b/kubernetes/feedback-viewer-ingress.yml.gpg deleted file mode 100644 index cbae20b9da33b158134fe7603a9bac02318e48d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1550 zcmV+p2J!if4Fm}T2t^9f7=>Y+>h#j;0Y)RvNX2Y49S5!2pa6DZ4N^3%PlDz`?4pKH ziz(i6NyMhY?PiT|id6~QFFRtWIEB5=PWer2mt6{s9BW}y0UYd%?>DX4GIuD>2BY$u zPgZse^7Ziyg&NC@48l%wAhcXj3k_nIPA`4~u|t#@y$~i5ziFwd^fZ9&A;F1zc=pFp zuYp{C%;q*B}WScO{VK`¬VCwc{!L-QWbwC2jG^K*}t z266K)m+y^~)KOO0(ne!vE(|Vovk+G}hR`R*XJ-t8M`E>-l0S>0g9r%I)&?~bPMKT> zjX&D@xUSVsX2T;cgf8CP6N4uq{5VHxY?8glyrR-e^V$Cx+QHdoGTf!XGNt-uHJ_Bg zCZX?`Hp;|=Yo;0>jUVXGN1Ze0OR!yaJJ5LDgb`wIl;iX3C=_+cxva&GW_~C}x7YXn zF5(;!L0~N>h^ZPY`kE}DnlDUZoZVLai@Q(71ysBpbSziQDMq$n4E2f13op1OOMm0f z;co0B3+B}Othle&Tq7m(Y=A7pM5Yig0?ed2ycgUV_12)^$0={C=MxRy2kLq+_@FVW zd_|QPL(MKVqxcxXqgzXDYQQK!xR z%b&Q48fbBzM4MUrSJb1AZL0aIGW$*}r!DFK<7;W;QBSC2+0Pe1O|pO=0rW!GmM8;p z6AWzpf4yiO&?js>v)4L8UY`Cuiy@yiwg0l0N)F#=6p)bewW6F&?MeV%rvR@mVf1=? zU7TeQ?)}@#@`c4?_SS%4Mu`QCmM{$7qf7ZBWTWksr23LG4>zhm;$EM$hEwltr_5MN z`s+mS_gtHcRl03>AR?qXVdNe6aY@m+1f+PumxEKdTZEw7f*zZmZIehPwm#K5=N7L% z3X{fUoK+||&R#hyO~z1P&Qi6~Oq`I=JgtATDmdxoL55|8Mf~PBeI#=&r8-pl&-n4R zsgQ3$E1^u(bmUX!53o4+0eZ+~~(yJ|boB z#L(Rc5=|{yiA3E8Hs~4&oN|a$ah_{_N~9ZH9YY0ua+O3*=FnJvmAf&&(|z8@q)G{H zfF{IP6^6C(b1ch=slV5`76oPlg%1Ne0aL)|twGRctY<;q7ml8BUQg(j^2T6DaK+ zF~DE!NS9X}!8`dHG+EM2E*XJuyI>MqD%G8QxMIg1jNGSHg`PFlqK>80e7?O}tn?!H zGVHqds6~Sn>k?J1nQ#VwF;%)wcUXEc-s5vjBF={Kc2At?@Zr28aV zuK+hd`+QF)+^viH0Id&^N=y&UEr3ludQCU2U>ov3HS1h$pI0+E7aQeI7I8JEK@UAeKHAenRXR z%i#@ob~ma@Mf#0!;kmsbi~)RnC|OOL)1-@?X{|+)6*hIxgt;MiQT!PL6pNYTKN2?j AQvd(} diff --git a/kubernetes/feedback-viewer-service.yml b/kubernetes/feedback-viewer-service.yml deleted file mode 100644 index 23933412..00000000 --- a/kubernetes/feedback-viewer-service.yml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: feedback-viewer -spec: - ports: - - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - app: feedback-viewer - sessionAffinity: None - type: ClusterIP From 9f3c912fefdb38838ee8243aca365e91b79fd7fd Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 10:57:46 -0500 Subject: [PATCH 071/105] hide settings --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0bd224b1..f37c8e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ docker/.env *.iml .idea/ .vscode/ - +.settings # Kubernetes secrets kubernetes/feedback-viewer-ingress.yml From f8d8e9050138b95e18415adafc51af5ed3577c35 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 11:00:14 -0500 Subject: [PATCH 072/105] remove files --- .settings/org.eclipse.jdt.apt.core.prefs | 4 ---- .settings/org.springframework.ide.eclipse.boot.prefs | 2 -- scripts/helm/deployMongoDB.sh | 1 - scripts/helm/values.yml | 9 --------- 4 files changed, 16 deletions(-) delete mode 100644 .settings/org.eclipse.jdt.apt.core.prefs delete mode 100644 .settings/org.springframework.ide.eclipse.boot.prefs delete mode 100755 scripts/helm/deployMongoDB.sh delete mode 100644 scripts/helm/values.yml diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs deleted file mode 100644 index dfa4f3ad..00000000 --- a/.settings/org.eclipse.jdt.apt.core.prefs +++ /dev/null @@ -1,4 +0,0 @@ -eclipse.preferences.version=1 -org.eclipse.jdt.apt.aptEnabled=true -org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations -org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations diff --git a/.settings/org.springframework.ide.eclipse.boot.prefs b/.settings/org.springframework.ide.eclipse.boot.prefs deleted file mode 100644 index 46ee9e0c..00000000 --- a/.settings/org.springframework.ide.eclipse.boot.prefs +++ /dev/null @@ -1,2 +0,0 @@ -eclipse.preferences.version=1 -spring.boot.launch.profile.history=local; diff --git a/scripts/helm/deployMongoDB.sh b/scripts/helm/deployMongoDB.sh deleted file mode 100755 index e05b6942..00000000 --- a/scripts/helm/deployMongoDB.sh +++ /dev/null @@ -1 +0,0 @@ -helm install -f values.yml mongodb bitnami/mongodb --set usePassword=false --namespace pagesuccess \ No newline at end of file diff --git a/scripts/helm/values.yml b/scripts/helm/values.yml deleted file mode 100644 index c0468dd1..00000000 --- a/scripts/helm/values.yml +++ /dev/null @@ -1,9 +0,0 @@ -usePassword: false -mongodbExtraFlags: "--wiredTigerCacheSizeGB=1" -resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 256Mi From 2ca28e1fe32a8b931bd7b8f0cfd9533560b367c9 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 11:02:00 -0500 Subject: [PATCH 073/105] remove unneeded files --- scripts/decrypt.sh | 8 -------- scripts/decrypt_whitelist.sh | 2 +- scripts/encrypt_whitelist.sh | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) delete mode 100755 scripts/decrypt.sh diff --git a/scripts/decrypt.sh b/scripts/decrypt.sh deleted file mode 100755 index 2466ef0f..00000000 --- a/scripts/decrypt.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -export GPG_TTY=$(tty) -gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ ---output ./src/main/resources/application.properties ./src/main/resources/application.properties.gpg -ls ./src/main/resources -gpg --quiet --batch --yes --decrypt --passphrase="$APPLICATION_PROPERTIES_PASSPHRASE" \ ---output kubernetes/feedback-viewer-ingress.yml kubernetes/feedback-viewer-ingress.yml.gpg -ls kubernetes diff --git a/scripts/decrypt_whitelist.sh b/scripts/decrypt_whitelist.sh index fb8529fc..4870f2f5 100755 --- a/scripts/decrypt_whitelist.sh +++ b/scripts/decrypt_whitelist.sh @@ -38,4 +38,4 @@ if [ $? -eq 0 ]; then else echo -e "${RED}✗ Decryption failed${NC}" exit 1 -fi +fi \ No newline at end of file diff --git a/scripts/encrypt_whitelist.sh b/scripts/encrypt_whitelist.sh index fcc30f36..85538e4d 100755 --- a/scripts/encrypt_whitelist.sh +++ b/scripts/encrypt_whitelist.sh @@ -41,4 +41,4 @@ if [ $? -eq 0 ]; then else echo -e "${RED}✗ Encryption failed${NC}" exit 1 -fi +fi \ No newline at end of file From e849445e0d5cd5cafcbd5ab71ede5f81f95b0376 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 11:03:40 -0500 Subject: [PATCH 074/105] fix: upgrade poi-ooxml from 4.1.2 to 5.4.0 to resolve CVE vulnerability --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 40f4568a..49a07ac8 100644 --- a/pom.xml +++ b/pom.xml @@ -74,12 +74,12 @@ org.apache.poi poi - 4.1.2 + 5.4.0 org.apache.poi poi-ooxml - 4.1.2 + 5.4.0 From 42c8cd087dd487993406561a7bc738ef58560800 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:06:14 -0500 Subject: [PATCH 075/105] Potential fix for code scanning alert no. 17: Information exposure through an error message Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/main/java/ca/gc/tbs/controller/BadWordController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ca/gc/tbs/controller/BadWordController.java b/src/main/java/ca/gc/tbs/controller/BadWordController.java index e2993fd1..9807945a 100644 --- a/src/main/java/ca/gc/tbs/controller/BadWordController.java +++ b/src/main/java/ca/gc/tbs/controller/BadWordController.java @@ -493,7 +493,7 @@ public void exportCsv(HttpServletResponse response) { } catch (Exception e) { LOG.error("Error importing CSV file", e); - return "Error: " + e.getMessage(); + return "Error: Failed to import CSV file."; } } From e3e7bf14513c9774a28c1875ba252e09782a16dd Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 11:08:33 -0500 Subject: [PATCH 076/105] fix: remove deprecated Bintray repository references --- pom.xml | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/pom.xml b/pom.xml index 49a07ac8..e047ba85 100644 --- a/pom.xml +++ b/pom.xml @@ -19,28 +19,6 @@ 1.8 - - - - false - - bintray-gov-uk-notify-maven - bintray - https://dl.bintray.com/gov-uk-notify/maven - - - - - - - false - - bintray-gov-uk-notify-maven - bintray-plugins - https://dl.bintray.com/gov-uk-notify/maven - - - com.github.netfarmers From 38d26345baa8b9e7f6b9eab4e20569644a208526 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:11:54 -0500 Subject: [PATCH 077/105] Potential fix for code scanning alert no. 14: Information exposure through an error message Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/main/java/ca/gc/tbs/controller/BadWordController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ca/gc/tbs/controller/BadWordController.java b/src/main/java/ca/gc/tbs/controller/BadWordController.java index 9807945a..8425e1db 100644 --- a/src/main/java/ca/gc/tbs/controller/BadWordController.java +++ b/src/main/java/ca/gc/tbs/controller/BadWordController.java @@ -251,7 +251,7 @@ private String getData(String lang) { return "Success"; } catch (Exception e) { LOG.error("Error creating badword entry", e); - return "Error: " + e.getMessage(); + return "Error: Unable to create badword entry"; } } From b2bc2bdacb324cf7bf7ddc272bd0676125adc981 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:16:00 -0500 Subject: [PATCH 078/105] Potential fix for code scanning alert no. 15: Information exposure through an error message Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/main/java/ca/gc/tbs/controller/BadWordController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ca/gc/tbs/controller/BadWordController.java b/src/main/java/ca/gc/tbs/controller/BadWordController.java index 8425e1db..d098f4a9 100644 --- a/src/main/java/ca/gc/tbs/controller/BadWordController.java +++ b/src/main/java/ca/gc/tbs/controller/BadWordController.java @@ -345,7 +345,7 @@ private String getData(String lang) { return "Success"; } catch (Exception e) { LOG.error("Error updating badword entry", e); - return "Error: " + e.getMessage(); + return "Error updating entry"; } } @@ -369,7 +369,7 @@ private String getData(String lang) { return "Success"; } catch (Exception e) { LOG.error("Error deleting badword entry", e); - return "Error: " + e.getMessage(); + return "Error deleting entry"; } } From e87858a0cb86835c0f3efb5d91cb7bf211fb33de Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Mon, 16 Feb 2026 11:16:28 -0500 Subject: [PATCH 079/105] fix error message --- src/main/java/ca/gc/tbs/controller/BadWordController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/ca/gc/tbs/controller/BadWordController.java b/src/main/java/ca/gc/tbs/controller/BadWordController.java index d098f4a9..b1878344 100644 --- a/src/main/java/ca/gc/tbs/controller/BadWordController.java +++ b/src/main/java/ca/gc/tbs/controller/BadWordController.java @@ -13,7 +13,6 @@ import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.CSVRecord; -import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; @@ -369,7 +368,7 @@ private String getData(String lang) { return "Success"; } catch (Exception e) { LOG.error("Error deleting badword entry", e); - return "Error deleting entry"; + return "Error deleting badword entry"; } } From 2855a604252eeaa21c62cc3f6340d27c2682154d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 18 Feb 2026 09:14:36 -0500 Subject: [PATCH 080/105] feat: add security.txt ALB listener rule for HackerOne --- terragrunt/aws/load_balancer/load_balancer.tf | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/terragrunt/aws/load_balancer/load_balancer.tf b/terragrunt/aws/load_balancer/load_balancer.tf index d277d699..065d22c2 100644 --- a/terragrunt/aws/load_balancer/load_balancer.tf +++ b/terragrunt/aws/load_balancer/load_balancer.tf @@ -42,6 +42,38 @@ resource "aws_lb_listener" "feedback_viewer_listener" { }) } +# Serve security.txt as a fixed response from the ALB +resource "aws_alb_listener_rule" "security_txt" { + listener_arn = aws_lb_listener.feedback_viewer_listener.arn + priority = 1 + + action { + type = "fixed-response" + + fixed_response { + content_type = "text/plain" + message_body = <<-EOT + Contact: mailto:ZZTBSCYBERS@tbs-sct.gc.ca + Contact: https://hackerone.com/tbs-sct/ + Canonical: https://${replace(var.domain, "/^[^.]+\\./", "")}/.well-known/security.txt + Expires: 2026-03-02T12:00:00.000Z + Preferred-Languages: en, fr + EOT + status_code = "200" + } + } + + condition { + path_pattern { + values = ["/.well-known/security.txt"] + } + } + + tags = merge(var.default_tags, { + CostCentre = var.billing_code + }) +} + resource "aws_lb_target_group" "feedback_viewer" { name = var.product_name port = 3001 From b671178b3051a4b38800839bf3de34afd260fb93 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 11:45:18 -0500 Subject: [PATCH 081/105] Revert docker/Dockerfile and remove main.yml - keep only code changes --- .github/workflows/main.yml | 51 --------------------------- docker/Dockerfile | 70 +++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 5d6b078e..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Deploy to AKS Cluster -on: - push: - branches: - - main -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: Decrypt large secret - run: ./scripts/decrypt.sh - env: - APPLICATION_PROPERTIES_PASSPHRASE: ${{ secrets.APPLICATION_PROPERTIES_PASSPHRASE }} - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: "21" - distribution: "temurin" - - - name: Build with Maven - run: mvn install --file pom.xml - - - uses: Azure/docker-login@v1 - with: - login-server: tbsacr.azurecr.io - username: ${{ secrets.ACR_USERNAME }} - password: ${{ secrets.ACR_PASSWORD }} - - - run: | - docker build -f ./docker/Dockerfile . -t tbsacr.azurecr.io/feedback-viewer:${{ github.sha }} - docker push tbsacr.azurecr.io/feedback-viewer:${{ github.sha }} - - # Set the target AKS cluster. - - uses: Azure/aks-set-context@v1 - with: - creds: "${{ secrets.AZURE_CREDENTIALS }}" - cluster-name: tbs-prod-aks - resource-group: tbs-prod-rg - - - uses: Azure/k8s-deploy@v1 - with: - manifests: | - kubernetes/feedback-viewer-deployment.yml - kubernetes/feedback-viewer-service.yml - kubernetes/feedback-viewer-ingress.yml - images: | - tbsacr.azurecr.io/feedback-viewer:${{ github.sha }} - namespace: | - pagesuccess diff --git a/docker/Dockerfile b/docker/Dockerfile index 5085dff9..06d23be4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,62 @@ -FROM maven:3.9.9-eclipse-temurin-21 -RUN rm -rf /var/lib/apt/lists/* -RUN apt-get update && apt-get install -y \ - # add any necessary packages here - && apt-get clean -RUN mkdir -p /app -ADD target/PageSuccess-0.0.1-SNAPSHOT.jar /app/app.jar -ENTRYPOINT ["java","-jar","/app/app.jar"] +# Build stage +FROM maven:3.8.3-jdk-8 AS build +WORKDIR /build + +# Copy pom.xml and download dependencies (cached layer) +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Copy decrypted whitelist file (decrypted in GitHub Actions) +COPY secrets/gc_ip_whitelist.txt ./secrets/ + +# Build the application +RUN mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:8-jre-jammy + +WORKDIR /app + +# Install required tools +RUN apt-get update && \ + apt-get install -y wget ca-certificates openssl perl && \ + rm -rf /var/lib/apt/lists/* + +# Create truststore directory +RUN mkdir -p /app/certs + +# Download and create truststore with AWS DocumentDB certificates +# Following AWS official documentation: https://docs.aws.amazon.com/documentdb/latest/developerguide/connect_programmatically.html +RUN cd /app/certs && \ + wget -O global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem && \ + awk 'split_after == 1 {n++;split_after=0} /-----END CERTIFICATE-----/ {split_after=1}{print > "rds-ca-" n ".pem"}' < global-bundle.pem && \ + for CERT in rds-ca-*.pem; do \ + alias=$(openssl x509 -noout -text -in $CERT | perl -ne 'next unless /Subject:/; s/.*(CN=|CN = )//; print'); \ + echo "Importing $alias"; \ + keytool -import -file ${CERT} -alias "${alias}" -storepass changeit -keystore rds-truststore.jks -noprompt; \ + rm $CERT; \ + done && \ + rm global-bundle.pem + +# Verify truststore contents +RUN keytool -list -keystore /app/certs/rds-truststore.jks -storepass changeit + +# Create config directory for whitelist file +RUN mkdir -p /app/config + +# Copy application JAR and decrypted whitelist file from build stage +COPY --from=build /build/target/PageSuccess-0.0.1-SNAPSHOT.jar app.jar +COPY --from=build /build/secrets/gc_ip_whitelist.txt /app/config/gc_ip_whitelist.txt + +ENV SERVER_PORT=3001 +ENV GC_IP_FILTER_WHITELIST_FILE=/app/config/gc_ip_whitelist.txt +EXPOSE 3001 + +# Set JVM system properties to use the custom truststore +ENTRYPOINT ["java", \ + "-Djavax.net.ssl.trustStore=/app/certs/rds-truststore.jks", \ + "-Djavax.net.ssl.trustStorePassword=changeit", \ + "-jar", "app.jar"] From 77fbb9e776b42f1190cfb033422cefff437f1f3c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 11:53:18 -0500 Subject: [PATCH 082/105] Remove agents.md, update Dockerfile to Java 21 to match pom.xml --- agents.md | 62 ----------------------------------------------- docker/Dockerfile | 4 +-- 2 files changed, 2 insertions(+), 64 deletions(-) delete mode 100644 agents.md diff --git a/agents.md b/agents.md deleted file mode 100644 index c3e93072..00000000 --- a/agents.md +++ /dev/null @@ -1,62 +0,0 @@ -# Workflow Orchestration - -## 1. Plan Node Default - -- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) -- If something goes sideways, STOP and re-plan immediately – don't keep pushing -- Use plan mode for verification steps, not just building -- Write detailed specs upfront to reduce ambiguity - -## 2. Subagent Strategy - -- Use subagents liberally to keep main context window clean -- Offload research, exploration, and parallel analysis to subagents -- For complex problems, throw more compute at it via subagents -- One task per subagent for focused execution - -## 3. Self-Improvement Loop - -- After ANY correction from the user: update `tasks/lessons.md` with the pattern -- Write rules for yourself that prevent the same mistake -- Ruthlessly iterate on these lessons until mistake rate drops -- Review lessons at session start for relevant project - -## 4. Verification Before Done - -- Never mark a task complete without proving it works -- Diff behavior between main and your changes when relevant -- Ask yourself: "Would a staff engineer approve this?" -- Run tests, check logs, demonstrate correctness - -## 5. Demand Elegance (Balanced) - -- For non-trivial changes: pause and ask "is there a more elegant way?" -- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" -- Skip this for simple, obvious fixes – don't over-engineer -- Challenge your own work before presenting it - -## 6. Autonomous Bug Fixing - -- When given a bug report: just fix it. Don't ask for hand-holding -- Point at logs, errors, failing tests – then resolve them -- Zero context switching required from the user -- Go fix failing CI tests without being told how - ---- - -# Task Management - -1. **Plan First:** Write plan to `tasks/todo.md` with checkable items -2. **Verify Plan:** Check in before starting implementation -3. **Track Progress:** Mark items complete as you go -4. **Explain Changes:** High-level summary at each step -5. **Document Results:** Add review section to `tasks/todo.md` -6. **Capture Lessons:** Update `tasks/lessons.md` after corrections - ---- - -# Core Principles - -- **Simplicity First:** Make every change as simple as possible. Impact minimal code. -- **No Laziness:** Find root causes. No temporary fixes. Senior developer standards. -- **Minimal Impact:** Changes should only touch what's necessary. Avoid introducing bugs. diff --git a/docker/Dockerfile b/docker/Dockerfile index 06d23be4..0ec14f8e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM maven:3.8.3-jdk-8 AS build +FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /build # Copy pom.xml and download dependencies (cached layer) @@ -16,7 +16,7 @@ COPY secrets/gc_ip_whitelist.txt ./secrets/ RUN mvn clean package -DskipTests # Runtime stage -FROM eclipse-temurin:8-jre-jammy +FROM eclipse-temurin:21-jre-jammy WORKDIR /app From 9e5dfddc6905fb78ffaf656cb131fea50a8429c5 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 11:54:41 -0500 Subject: [PATCH 083/105] Fix CSRF vulnerability: remove csrf.disable() to use Spring default CSRF protection --- src/main/java/ca/gc/tbs/config/WebSecurityConfig.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java b/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java index d9074e04..c4df187f 100644 --- a/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java +++ b/src/main/java/ca/gc/tbs/config/WebSecurityConfig.java @@ -34,7 +34,6 @@ public WebSecurityConfig( @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/createApiUser").hasAuthority("ADMIN") .requestMatchers("/authenticate").permitAll() From c4b44a35c5bdbca0cf3becdd0883d2f1dbbac74c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 11:55:30 -0500 Subject: [PATCH 084/105] Fix poi-ooxml vulnerability: update from 5.3.0 to 5.4.0 (GHSA-gmg8-593g-7mv3) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 9804a282..f569eb60 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,7 @@ org.apache.poi poi-ooxml - 5.3.0 + 5.4.0 From dc0b7f3f5a81a26ce5a674bff3191cb1ddb4f74d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 12:13:14 -0500 Subject: [PATCH 085/105] Fix javax.servlet to jakarta.servlet for Spring Boot 3 / Java 21 compatibility --- src/main/java/ca/gc/tbs/filter/GcIpFilter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/ca/gc/tbs/filter/GcIpFilter.java b/src/main/java/ca/gc/tbs/filter/GcIpFilter.java index 9d7a375d..6ef66a50 100644 --- a/src/main/java/ca/gc/tbs/filter/GcIpFilter.java +++ b/src/main/java/ca/gc/tbs/filter/GcIpFilter.java @@ -8,9 +8,9 @@ import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import javax.servlet.*; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; From c90590aa24bfbdecd6f4abd92c5cdfbe105d1269 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 12:19:45 -0500 Subject: [PATCH 086/105] Fix critical Tomcat vulnerability: override tomcat-embed-core to 10.1.49 --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index f569eb60..5aae0763 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,11 @@ + + org.apache.tomcat.embed + tomcat-embed-core + 10.1.49 + org.springframework.boot spring-boot-starter-data-jpa From 02ef6f3d2f686985aea29588b855565807702767 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 12:21:22 -0500 Subject: [PATCH 087/105] Fix critical Spring Security vulnerability: override spring-security-core to 6.4.10 --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index 5aae0763..61d664c0 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,11 @@ tomcat-embed-core 10.1.49 + + org.springframework.security + spring-security-core + 6.4.10 + org.springframework.boot spring-boot-starter-data-jpa From 386f5a0275d61ae4441dc505e75e329e01a5c8ab Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 13:32:34 -0500 Subject: [PATCH 088/105] fix: add missing org.slf4j.Logger import in BadWords.java --- src/main/java/ca/gc/tbs/service/BadWords.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/ca/gc/tbs/service/BadWords.java b/src/main/java/ca/gc/tbs/service/BadWords.java index ad26450c..e25252dd 100644 --- a/src/main/java/ca/gc/tbs/service/BadWords.java +++ b/src/main/java/ca/gc/tbs/service/BadWords.java @@ -10,6 +10,7 @@ import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; From 9a95d5bd407002ab815f086f78aae01aa650ebb1 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 13:35:14 -0500 Subject: [PATCH 089/105] fix: upgrade dependencies to resolve Dependabot security alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spring Boot: 3.4.2 → 3.4.13 (fixes Spring Framework, logback, commons-lang3, json-smart, assertj CVEs) - Spring Security: 6.4.10 → 6.4.13 (fixes password length enforcement) - Log4j: pin to 2.25.3 (fixes TLS hostname verification) - Xerces: exclude old transitive dep from nekohtml, add xercesImpl 2.12.2 (fixes XML validation, DoS, infinite loop, input validation CVEs) --- pom.xml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 61d664c0..9d443f9f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.4.2 + 3.4.13 ca.gc.tbs @@ -28,7 +28,7 @@ org.springframework.security spring-security-core - 6.4.10 + 6.4.13 org.springframework.boot @@ -44,6 +44,17 @@ net.sourceforge.nekohtml nekohtml 1.9.21 + + + xerces + xercesImpl + + + + + xerces + xercesImpl + 2.12.2 org.springframework.boot @@ -170,11 +181,13 @@ org.apache.logging.log4j log4j-api + 2.25.3 compile org.apache.logging.log4j log4j-core + 2.25.3 compile From 07a173dfc61973c7bdfffa9d0a310638d0f62947 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 13:37:17 -0500 Subject: [PATCH 090/105] fix: resolve CodeQL code scanning alerts - LanguageFilter.java: fix regex injection by using Pattern.quote() - UserController.java: fix info exposure by logging errors server-side instead of returning e.getMessage() - Add permissions to 5 workflow files (shellcheck, s3-backup, labels, export_github_data, backstage-catalog-helper) --- .../workflows/backstage-catalog-helper.yml | 15 +++--- .github/workflows/export_github_data.yml | 3 ++ .github/workflows/labels.yml | 6 ++- .github/workflows/s3-backup.yml | 52 ++++++++++--------- .github/workflows/shellcheck.yml | 14 ++--- .../ca/gc/tbs/controller/UserController.java | 9 ++-- .../java/ca/gc/tbs/filter/LanguageFilter.java | 3 +- 7 files changed, 59 insertions(+), 43 deletions(-) diff --git a/.github/workflows/backstage-catalog-helper.yml b/.github/workflows/backstage-catalog-helper.yml index 1695cecf..35ed41c9 100644 --- a/.github/workflows/backstage-catalog-helper.yml +++ b/.github/workflows/backstage-catalog-helper.yml @@ -2,6 +2,9 @@ name: Backstage Catalog Info Helper on: workflow_dispatch: +permissions: + contents: read + jobs: update-catalog-info: runs-on: ubuntu-latest @@ -27,10 +30,10 @@ jobs: with: token: ${{ steps.generate_token.outputs.token}} sign-commits: true - commit-message: 'Add catalog-info.yaml' - branch: 'backstage/catalog-info' - title: 'Add catalog-info.yaml' - body: 'Adding a basic catalog-info.yaml to start populating the backstage catalog with your components.' - labels: 'backstage' + commit-message: "Add catalog-info.yaml" + branch: "backstage/catalog-info" + title: "Add catalog-info.yaml" + body: "Adding a basic catalog-info.yaml to start populating the backstage catalog with your components." + labels: "backstage" add-paths: | - catalog-info.yaml \ No newline at end of file + catalog-info.yaml diff --git a/.github/workflows/export_github_data.yml b/.github/workflows/export_github_data.yml index fe9bd969..b44c5568 100644 --- a/.github/workflows/export_github_data.yml +++ b/.github/workflows/export_github_data.yml @@ -4,6 +4,9 @@ on: schedule: - cron: "20 7 * * *" +permissions: + contents: read + jobs: export-data: runs-on: ubuntu-latest diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 7c8da4ed..bd3b5d28 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_dispatch: +permissions: + issues: write + pull-requests: write + jobs: sync-labels: runs-on: ubuntu-latest @@ -11,4 +15,4 @@ jobs: steps: - uses: cds-snc/labels@v1 with: - github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/s3-backup.yml b/.github/workflows/s3-backup.yml index 09801b11..22f75f2a 100644 --- a/.github/workflows/s3-backup.yml +++ b/.github/workflows/s3-backup.yml @@ -4,36 +4,38 @@ on: schedule: - cron: "0 6 * * *" +permissions: + contents: read + jobs: s3-backup: runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # retrieve all history - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 # retrieve all history - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 - with: - aws-access-key-id: ${{ secrets.AWS_S3_BACKUP_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_S3_BACKUP_SECRET_ACCESS_KEY }} - aws-region: ca-central-1 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_S3_BACKUP_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_S3_BACKUP_SECRET_ACCESS_KEY }} + aws-region: ca-central-1 - - name: Create ZIP bundle - run: | - ZIP_FILE=`basename ${{ github.repository }}`-`date '+%Y-%m-%d'`.zip - zip -rq "${ZIP_FILE}" . - mkdir -p ${{ github.repository }} - mv "${ZIP_FILE}" ${{ github.repository }} + - name: Create ZIP bundle + run: | + ZIP_FILE=`basename ${{ github.repository }}`-`date '+%Y-%m-%d'`.zip + zip -rq "${ZIP_FILE}" . + mkdir -p ${{ github.repository }} + mv "${ZIP_FILE}" ${{ github.repository }} - - name: Upload to S3 bucket - run: | - aws s3 sync . s3://${{ secrets.AWS_S3_BACKUP_BUCKET }} --exclude='*' --include='${{ github.repository }}/*' + - name: Upload to S3 bucket + run: | + aws s3 sync . s3://${{ secrets.AWS_S3_BACKUP_BUCKET }} --exclude='*' --include='${{ github.repository }}/*' - - name: Notify Slack channel if this job failed - if: ${{ failure() }} - run: | - json='{"text":"S3 backup failed in !"}' - curl -X POST -H 'Content-type: application/json' --data "$json" ${{ secrets.SLACK_NOTIFY_WEBHOOK }} + - name: Notify Slack channel if this job failed + if: ${{ failure() }} + run: | + json='{"text":"S3 backup failed in !"}' + curl -X POST -H 'Content-type: application/json' --data "$json" ${{ secrets.SLACK_NOTIFY_WEBHOOK }} diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 188ac134..c92a86ad 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -4,14 +4,14 @@ on: paths: - "**/*.sh" +permissions: + contents: read + jobs: shellcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Shellcheck - run: | - .github/workflows/scripts/run-shellcheck.sh - - - + - uses: actions/checkout@v2 + - name: Shellcheck + run: | + .github/workflows/scripts/run-shellcheck.sh diff --git a/src/main/java/ca/gc/tbs/controller/UserController.java b/src/main/java/ca/gc/tbs/controller/UserController.java index f975a4c6..e672d011 100644 --- a/src/main/java/ca/gc/tbs/controller/UserController.java +++ b/src/main/java/ca/gc/tbs/controller/UserController.java @@ -28,7 +28,8 @@ public class UserController { this.service.enable(request.getParameter("id")); return "Updated"; } catch (Exception e) { - return "Error:" + e.getMessage(); + LOG.error("Error updating user", e); + return "Error: an internal error occurred"; } } @@ -38,7 +39,8 @@ public class UserController { this.service.deleteUserById(request.getParameter("id")); return "deleted"; } catch (Exception e) { - return "Error:" + e.getMessage(); + LOG.error("Error deleting user", e); + return "Error: an internal error occurred"; } } @@ -50,7 +52,8 @@ public View enableAdmin(HttpServletRequest request, RedirectAttributes atts) { atts.addFlashAttribute("successMessage", "User has been enabled and admin"); return new RedirectView("/success"); } catch (Exception e) { - atts.addFlashAttribute("errorMessage", "Failed to enable admin." + e.getMessage()); + LOG.error("Failed to enable admin", e); + atts.addFlashAttribute("errorMessage", "Failed to enable admin. Please try again."); return new RedirectView("/error"); } } diff --git a/src/main/java/ca/gc/tbs/filter/LanguageFilter.java b/src/main/java/ca/gc/tbs/filter/LanguageFilter.java index 6a7d6390..ef197a30 100644 --- a/src/main/java/ca/gc/tbs/filter/LanguageFilter.java +++ b/src/main/java/ca/gc/tbs/filter/LanguageFilter.java @@ -1,6 +1,7 @@ package ca.gc.tbs.filter; import java.io.IOException; +import java.util.regex.Pattern; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -55,7 +56,7 @@ private String cleanQueryStringLangParam(HttpServletRequest req, String lang) { return ""; } - queryString = queryString.replaceAll("(^|&)lang=" + lang + "($|&)", "&"); + queryString = queryString.replaceAll("(^|&)lang=" + Pattern.quote(lang) + "($|&)", "&"); queryString = queryString.replaceAll("&&+", "&"); queryString = queryString.replaceAll("^&|&$", ""); From f99a7b8bf062c2dea83ef6d710465b75b2870ae6 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 13:57:50 -0500 Subject: [PATCH 091/105] fix: add missing Logger imports and patch dependency vulnerabilities - Add missing org.slf4j.Logger import to BadWordController and ProblemCacheService - Override assertj-core to 3.27.3 (fixes High XXE vulnerability) - Override commons-lang3 to 3.17.0 (fixes Moderate Uncontrolled Recursion) - Override logback to 1.5.16 (fixes Low class instantiation vulnerability) --- pom.xml | 4 ++++ src/main/java/ca/gc/tbs/controller/BadWordController.java | 1 + src/main/java/ca/gc/tbs/service/ProblemCacheService.java | 1 + 3 files changed, 6 insertions(+) diff --git a/pom.xml b/pom.xml index 9d443f9f..61980a6a 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,10 @@ 21 + + 3.27.3 + 3.17.0 + 1.5.16 diff --git a/src/main/java/ca/gc/tbs/controller/BadWordController.java b/src/main/java/ca/gc/tbs/controller/BadWordController.java index 12d52210..c1e023bf 100644 --- a/src/main/java/ca/gc/tbs/controller/BadWordController.java +++ b/src/main/java/ca/gc/tbs/controller/BadWordController.java @@ -13,6 +13,7 @@ import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.CSVRecord; +import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/src/main/java/ca/gc/tbs/service/ProblemCacheService.java b/src/main/java/ca/gc/tbs/service/ProblemCacheService.java index 0b4a6f4e..b0b77383 100644 --- a/src/main/java/ca/gc/tbs/service/ProblemCacheService.java +++ b/src/main/java/ca/gc/tbs/service/ProblemCacheService.java @@ -2,6 +2,7 @@ import java.util.List; +import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; From d265912217e7e329fdb6431898645bed42b817d3 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 14:00:08 -0500 Subject: [PATCH 092/105] fix: bump logback to 1.5.18, allow assertj GHSA in dependency review - Bump logback to 1.5.18 (fixes both GHSA-25qh-j22f-pwp8 and GHSA-qqpg-mvqg-649v) - Allow GHSA-rqfh-9r24-8c9r for assertj (no patched version available, test-scoped only) - Upgrade dependency-review-action to v4 and checkout to v4 --- .github/workflows/dependency-review.yml | 7 +++++-- pom.xml | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 02384c91..421fb700 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -10,7 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Dependency review - uses: actions/dependency-review-action@v1 \ No newline at end of file + uses: actions/dependency-review-action@v4 + with: + # assertj-core 3.27.3 is the latest stable; no patched version available yet (test-scoped only) + allow-ghsas: GHSA-rqfh-9r24-8c9r diff --git a/pom.xml b/pom.xml index 61980a6a..f71f5616 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 3.27.3 3.17.0 - 1.5.16 + 1.5.18 From 8f24332f62272c7611c70ca9d1fcdda6ddc98906 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 25 Feb 2026 14:07:29 -0500 Subject: [PATCH 093/105] fix: patch all dependency vulnerabilities and fix ReportController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertj-core → 3.27.7 (fixes GHSA-rqfh-9r24-8c9r, XXE) - commons-lang3 → 3.18.0 (fixes uncontrolled recursion) - logback → 1.5.25 (fixes GHSA-25qh-j22f-pwp8 ACE + GHSA-qqpg-mvqg-649v class instantiation) - Fix ReportController: replace javax.swing.text.View with RedirectView - Upgrade dependency-review-action to v4 --- .github/workflows/dependency-review.yml | 3 --- pom.xml | 6 +++--- src/main/java/ca/gc/tbs/controller/ReportController.java | 4 +--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 421fb700..7abbe58e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,6 +14,3 @@ jobs: - name: Dependency review uses: actions/dependency-review-action@v4 - with: - # assertj-core 3.27.3 is the latest stable; no patched version available yet (test-scoped only) - allow-ghsas: GHSA-rqfh-9r24-8c9r diff --git a/pom.xml b/pom.xml index f71f5616..5599cb3a 100644 --- a/pom.xml +++ b/pom.xml @@ -18,9 +18,9 @@ 21 - 3.27.3 - 3.17.0 - 1.5.18 + 3.27.7 + 3.18.0 + 1.5.25 diff --git a/src/main/java/ca/gc/tbs/controller/ReportController.java b/src/main/java/ca/gc/tbs/controller/ReportController.java index 8ed773ee..ae163dd4 100644 --- a/src/main/java/ca/gc/tbs/controller/ReportController.java +++ b/src/main/java/ca/gc/tbs/controller/ReportController.java @@ -7,8 +7,6 @@ import java.util.List; import java.util.Scanner; -import javax.swing.text.View; - import org.apache.commons.csv.CSVFormat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,7 +40,7 @@ public class ReportController { public ReportController() {} @GetMapping("/reports") - public View generateReports() throws Exception { + public RedirectView generateReports() throws Exception { new File(Paths.get(pythonPath + "/" + INPUT_FILENAME).toString()).delete(); BufferedWriter writer = Files.newBufferedWriter(Paths.get(pythonScriptPath + "/" + INPUT_FILENAME)); From 54740f47f9abb372e0e7cc068d3c6756af7c10b8 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Tue, 3 Mar 2026 09:28:35 -0500 Subject: [PATCH 094/105] fix: update sbom version --- .github/workflows/build_and_deploy_staging.yml | 2 +- pom.xml | 7 +++++++ src/main/java/ca/gc/tbs/controller/ReportController.java | 5 ++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_and_deploy_staging.yml b/.github/workflows/build_and_deploy_staging.yml index 76ef3774..a0340fdf 100644 --- a/.github/workflows/build_and_deploy_staging.yml +++ b/.github/workflows/build_and_deploy_staging.yml @@ -92,7 +92,7 @@ jobs: log_analytics_workspace_key: ${{ secrets.LOG_ANALYTICS_WORKSPACE_KEY }} - name: Docker generate SBOM - uses: cds-snc/security-tools/.github/actions/generate-sbom@34794baf2af592913bb5b51d8df4f8d0acc49b6f # v3.2.0 + uses: cds-snc/security-tools/.github/actions/generate-sbom@12a0cdea1c5a515dfcbe353693db804a1793c0ed # v4.0.1 env: TRIVY_DB_REPOSITORY: ${{ vars.TRIVY_DB_REPOSITORY }} with: diff --git a/pom.xml b/pom.xml index 5599cb3a..61785d59 100644 --- a/pom.xml +++ b/pom.xml @@ -109,6 +109,13 @@ spring-boot-starter-test test + + + org.assertj + assertj-core + 3.27.7 + test + org.apache.commons commons-csv diff --git a/src/main/java/ca/gc/tbs/controller/ReportController.java b/src/main/java/ca/gc/tbs/controller/ReportController.java index ae163dd4..fca2d388 100644 --- a/src/main/java/ca/gc/tbs/controller/ReportController.java +++ b/src/main/java/ca/gc/tbs/controller/ReportController.java @@ -15,7 +15,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.servlet.view.RedirectView; import ca.gc.tbs.domain.Problem; import ca.gc.tbs.repository.ProblemRepository; @@ -40,7 +39,7 @@ public class ReportController { public ReportController() {} @GetMapping("/reports") - public RedirectView generateReports() throws Exception { + public String generateReports() throws Exception { new File(Paths.get(pythonPath + "/" + INPUT_FILENAME).toString()).delete(); BufferedWriter writer = Files.newBufferedWriter(Paths.get(pythonScriptPath + "/" + INPUT_FILENAME)); @@ -82,7 +81,7 @@ public RedirectView generateReports() throws Exception { // call python this.executePython(); - return new RedirectView("/reports/view"); + return "redirect:/reports/view"; } @GetMapping("/reports/view") From 30ee0325f95ac2d582f234631005fa3aa3e651af Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Thu, 5 Mar 2026 10:11:12 -0500 Subject: [PATCH 095/105] feat: extend hacker one pilot till march 29th --- terragrunt/aws/load_balancer/load_balancer.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terragrunt/aws/load_balancer/load_balancer.tf b/terragrunt/aws/load_balancer/load_balancer.tf index 065d22c2..198c0911 100644 --- a/terragrunt/aws/load_balancer/load_balancer.tf +++ b/terragrunt/aws/load_balancer/load_balancer.tf @@ -56,7 +56,7 @@ resource "aws_alb_listener_rule" "security_txt" { Contact: mailto:ZZTBSCYBERS@tbs-sct.gc.ca Contact: https://hackerone.com/tbs-sct/ Canonical: https://${replace(var.domain, "/^[^.]+\\./", "")}/.well-known/security.txt - Expires: 2026-03-02T12:00:00.000Z + Expires: 2026-03-29T23:59:59.000Z Preferred-Languages: en, fr EOT status_code = "200" From 992898463b580d9a734b4f33dbba0b167766065d Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 06:42:36 -0400 Subject: [PATCH 096/105] fix: bump jackson-core and tomcat-embed-core for security --- pom.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 61785d59..07fa3291 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,12 @@ org.apache.tomcat.embed tomcat-embed-core - 10.1.49 + 10.1.50 + + + com.fasterxml.jackson.core + jackson-core + 2.18.6 org.springframework.security From fa2bab53b9bebd3ad158fc67ec0f0e111f263007 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 08:36:37 -0400 Subject: [PATCH 097/105] fix: remove unused python scripts and reports feature --- .../java/ca/gc/tbs/config/PageConfig.java | 9 -- .../gc/tbs/controller/ReportController.java | 118 ------------------ src/main/resources/templates/reports.html | 29 ----- 3 files changed, 156 deletions(-) delete mode 100644 src/main/java/ca/gc/tbs/controller/ReportController.java delete mode 100644 src/main/resources/templates/reports.html diff --git a/src/main/java/ca/gc/tbs/config/PageConfig.java b/src/main/java/ca/gc/tbs/config/PageConfig.java index 02b2e8ee..53309b63 100644 --- a/src/main/java/ca/gc/tbs/config/PageConfig.java +++ b/src/main/java/ca/gc/tbs/config/PageConfig.java @@ -5,7 +5,6 @@ */ package ca.gc.tbs.config; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; @@ -15,9 +14,6 @@ @Configuration public class PageConfig implements WebMvcConfigurer { - @Value("${pagesuccess.pythonScriptPath}") - private String pythonScriptPath; - @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/home").setViewName("home"); @@ -39,10 +35,5 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry .addResourceHandler("/templates/**") // « /templates/style.css .addResourceLocations("classpath:/templates/static/"); - - // File located on disk - registry - .addResourceHandler("/python/**") - .addResourceLocations("file://" + this.pythonScriptPath); } } diff --git a/src/main/java/ca/gc/tbs/controller/ReportController.java b/src/main/java/ca/gc/tbs/controller/ReportController.java deleted file mode 100644 index fca2d388..00000000 --- a/src/main/java/ca/gc/tbs/controller/ReportController.java +++ /dev/null @@ -1,118 +0,0 @@ -package ca.gc.tbs.controller; - -import java.io.BufferedWriter; -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.List; -import java.util.Scanner; - -import org.apache.commons.csv.CSVFormat; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.apache.commons.csv.CSVPrinter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -import ca.gc.tbs.domain.Problem; -import ca.gc.tbs.repository.ProblemRepository; - -@Controller -public class ReportController { - - private static final Logger log = LoggerFactory.getLogger(ReportController.class); - - @Autowired ProblemRepository problemRepository; - - @Value("${pagesuccess.pythonPath}") - private String pythonPath; - - @Value("${pagesuccess.pythonScriptPath}") - private String pythonScriptPath; - - public static String INPUT_FILENAME = "page_success_may_24.csv"; - - public static String PYTHON_SCRIPT = "page_success_widget.py"; - - public ReportController() {} - - @GetMapping("/reports") - public String generateReports() throws Exception { - new File(Paths.get(pythonPath + "/" + INPUT_FILENAME).toString()).delete(); - BufferedWriter writer = - Files.newBufferedWriter(Paths.get(pythonScriptPath + "/" + INPUT_FILENAME)); - - try (CSVPrinter csvPrinter = - new CSVPrinter( - writer, - CSVFormat.DEFAULT.withHeader( - "Ref Number", - "Date/time received", - "Page Title", - "Page URL", - "Y/N", - "What's wrong", - "Details", - "Topic", - "Personal info", - "(Y/N)", - "Notes", - "", - "Test - auto-topic generator"))) { - - List problems = this.problemRepository.findAll(); - for (Problem problem : problems) { - csvPrinter.printRecord( - problem.getId(), - problem.getProblemDate(), - problem.getTitle(), - problem.getUrl(), - "N", - problem.getProblemDetails(), - String.join(", ", problem.getTags()), - "N", - "", - ""); - } - csvPrinter.flush(); - } - // call python - this.executePython(); - - return "redirect:/reports/view"; - } - - @GetMapping("/reports/view") - public String viewReports() { - return "reports"; - } - - public int executePython() throws Exception { - File pathToExecutable = new File(this.pythonPath); - ProcessBuilder builder = new ProcessBuilder(pathToExecutable.getAbsolutePath(), PYTHON_SCRIPT); - builder.directory(new File(this.pythonScriptPath).getAbsoluteFile()); - builder.redirectErrorStream(true); - Process process = builder.start(); - - Scanner s = new Scanner(process.getInputStream()); - StringBuilder text = new StringBuilder(); - while (s.hasNextLine()) { - text.append(s.nextLine()); - text.append("\n"); - } - log.info("{}", text); - s.close(); - - Thread vt = Thread.ofVirtual().start(() -> { - try { - process.waitFor(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - vt.join(); - return process.exitValue(); - } -} diff --git a/src/main/resources/templates/reports.html b/src/main/resources/templates/reports.html deleted file mode 100644 index c2f3a73c..00000000 --- a/src/main/resources/templates/reports.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - \ No newline at end of file From 31039e663bb8c131420577331384e5cbab432c9c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh <32944558+HamzaAburaneh@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:53:05 -0400 Subject: [PATCH 098/105] Potential fix for code scanning alert no. 10: Information exposure through an error message Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/main/java/ca/gc/tbs/security/JWTFilter.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/gc/tbs/security/JWTFilter.java b/src/main/java/ca/gc/tbs/security/JWTFilter.java index a2a0fa8f..b6c0eab0 100644 --- a/src/main/java/ca/gc/tbs/security/JWTFilter.java +++ b/src/main/java/ca/gc/tbs/security/JWTFilter.java @@ -14,10 +14,14 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Component public class JWTFilter extends OncePerRequestFilter { + private static final Logger logger = LoggerFactory.getLogger(JWTFilter.class); + @Autowired private JWTUtil jwtUtil; @Autowired private UserService userService; @@ -36,8 +40,9 @@ protected void doFilterInternal( try { userName = jwtUtil.extractUsername(token); } catch (RuntimeException ex) { + logger.warn("Failed to extract username from JWT token", ex); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - response.getWriter().write(ex.getMessage()); + response.getWriter().write("Invalid authentication token."); return; } } From 7be3e004dab9b9d0d28c02d5881217474c0b4171 Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 09:03:52 -0400 Subject: [PATCH 099/105] fix: remove unused email service and notification dependency --- pom.xml | 5 -- .../ca/gc/tbs/controller/LoginController.java | 4 - .../java/ca/gc/tbs/service/EmailService.java | 83 ------------------- 3 files changed, 92 deletions(-) delete mode 100644 src/main/java/ca/gc/tbs/service/EmailService.java diff --git a/pom.xml b/pom.xml index 07fa3291..e19e922e 100644 --- a/pom.xml +++ b/pom.xml @@ -126,11 +126,6 @@ commons-csv 1.8 - - uk.gov.service.notify - notifications-java-client - 5.2.1-RELEASE - org.springframework.boot spring-boot-starter-thymeleaf diff --git a/src/main/java/ca/gc/tbs/controller/LoginController.java b/src/main/java/ca/gc/tbs/controller/LoginController.java index 8606ad5f..7745cc28 100644 --- a/src/main/java/ca/gc/tbs/controller/LoginController.java +++ b/src/main/java/ca/gc/tbs/controller/LoginController.java @@ -1,7 +1,6 @@ package ca.gc.tbs.controller; import ca.gc.tbs.domain.User; -import ca.gc.tbs.service.EmailService; import ca.gc.tbs.service.UserService; import java.text.SimpleDateFormat; import jakarta.servlet.http.HttpServletRequest; @@ -23,8 +22,6 @@ public class LoginController { @Autowired private UserService userService; - @Autowired private EmailService emailService; - @RequestMapping(value = "/login", method = RequestMethod.GET) public ModelAndView login(HttpServletRequest request) throws Exception { ModelAndView modelAndView = new ModelAndView(); @@ -59,7 +56,6 @@ public ModelAndView signup(HttpServletRequest request) { @RequestMapping(value = "/signup", method = RequestMethod.POST) public RedirectView createNewUser(@Valid User user, RedirectAttributes atts) { userService.saveUser(user); - emailService.sendUserActivationRequestEmail(user.getEmail()); atts.addFlashAttribute( "successMessage", "User has been registered successfully. You will be notified when the account has been" diff --git a/src/main/java/ca/gc/tbs/service/EmailService.java b/src/main/java/ca/gc/tbs/service/EmailService.java deleted file mode 100644 index 68677ddc..00000000 --- a/src/main/java/ca/gc/tbs/service/EmailService.java +++ /dev/null @@ -1,83 +0,0 @@ -package ca.gc.tbs.service; - -import ca.gc.tbs.domain.User; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.List; -import java.util.Map; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import uk.gov.service.notify.NotificationClient; - -@Service -public class EmailService { - - private record UserActivationPersonalisation(String email, String loginURL) { - Map toMap() { - return Map.of("email", email(), "loginURL", loginURL()); - } - } - - - private final String userActivationRequestKey; - - private final String accountEnabledKey; - - private final String loginURL; - - private final UserService userService; - - public EmailService( - @Value("${notify.templateid.accountenabled}") String userActivationRequestKey, - @Value("${notify.templateid.useractivationrequest}") String accountEnabledKey, - @Value("${pagesuccess.loginURL}") String loginURL, - UserService userService) { - this.userActivationRequestKey = userActivationRequestKey; - this.accountEnabledKey = accountEnabledKey; - this.loginURL = loginURL; - this.userService = userService; - } - - public String getUserActivationRequestKey() { - return userActivationRequestKey; - } - - public String getAccountEnabledKey() { - return accountEnabledKey; - } - - public NotificationClient getNotificationClient() { - return new NotificationClient(getAPIKey(), "https://api.notification.alpha.canada.ca"); - } - - private String getAPIKey() { - try { - File file = - new File( - getClass() - .getClassLoader() - .getResource("static/secrets/notification.secret") - .getFile()); - return new String( - Files.readAllBytes(Paths.get(file.getCanonicalPath())), StandardCharsets.UTF_8); - } catch (Exception e) { - - } - return ""; - } - - public void sendUserActivationRequestEmail(String email) { - Map personalisation = new UserActivationPersonalisation(email, loginURL).toMap(); - List admins = this.userService.findUserByRole(UserService.ADMIN_ROLE); - for (User user : admins) { - try { - this.getNotificationClient() - .sendEmail(this.userActivationRequestKey, user.getEmail(), personalisation, ""); - } catch (Exception e) { - System.out.println(e.getMessage()); - } - } - } -} From 2ac7d6d3309a09183219c90aacd5f160d118267e Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 09:47:43 -0400 Subject: [PATCH 100/105] fix: register gcIpCache and add RDAP request timeout to prevent ECS health check failures - Add 'gcIpCache' to CacheManager so @Cacheable on GcIpValidationService works correctly - Add 5s connect/read timeout to RestTemplate to prevent hung RDAP calls from blocking Tomcat threads and causing ELB health check failures Co-Authored-By: Claude Sonnet 4.6 --- src/main/java/ca/gc/tbs/config/CacheConfig.java | 9 +++++---- .../java/ca/gc/tbs/service/GcIpValidationService.java | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/ca/gc/tbs/config/CacheConfig.java b/src/main/java/ca/gc/tbs/config/CacheConfig.java index 25cbf4b2..9cebeb49 100644 --- a/src/main/java/ca/gc/tbs/config/CacheConfig.java +++ b/src/main/java/ca/gc/tbs/config/CacheConfig.java @@ -17,10 +17,11 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager( - "problemDates", - "distinctUrls", - "processedProblems", - "dashboardStats" + "problemDates", + "distinctUrls", + "processedProblems", + "dashboardStats", + "gcIpCache" ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(24, TimeUnit.HOURS) diff --git a/src/main/java/ca/gc/tbs/service/GcIpValidationService.java b/src/main/java/ca/gc/tbs/service/GcIpValidationService.java index 76c050b0..fb2e6335 100644 --- a/src/main/java/ca/gc/tbs/service/GcIpValidationService.java +++ b/src/main/java/ca/gc/tbs/service/GcIpValidationService.java @@ -5,6 +5,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -25,7 +26,10 @@ public class GcIpValidationService { private final ObjectMapper objectMapper; public GcIpValidationService() { - this.restTemplate = new RestTemplate(); + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(5000); + this.restTemplate = new RestTemplate(factory); this.objectMapper = new ObjectMapper(); } From cad0ef02fcdd469d0d07b48943f620a53ca54fae Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 09:58:41 -0400 Subject: [PATCH 101/105] fix: add updated IP to GC IP whitelist Co-Authored-By: Claude Sonnet 4.6 --- secrets/gc_ip_whitelist.txt.gpg | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/secrets/gc_ip_whitelist.txt.gpg b/secrets/gc_ip_whitelist.txt.gpg index d2cfe24e..51f02767 100644 --- a/secrets/gc_ip_whitelist.txt.gpg +++ b/secrets/gc_ip_whitelist.txt.gpg @@ -1,5 +1 @@ -  ;h%_`0$9-7gWf2 ؘx -a!=w㹵9I9f -0e+6137\:7 f!~y PՔKλM ߎGSDJ -gήa$EH@nyW2k)IRNJ" -37ˁN}QB/+h^9`ٓعkDӡj"gؚm%ӱsӪ%^ iJ)JDDp>f09 yɩ= O[| c. auth .requestMatchers("/createApiUser").hasAuthority("ADMIN") .requestMatchers("/authenticate").permitAll() + .requestMatchers("/health", "/actuator/health").permitAll() .requestMatchers("/api/user/**").hasRole("USER") .requestMatchers("/", "/checkExists", "/error", "/enableAdmin", "/login", "/signup", "/success").permitAll() .requestMatchers("/u/**").hasAnyAuthority("ADMIN") From edae7a4908f96462f4e15901a44f99014e951e7e Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 12:58:19 -0400 Subject: [PATCH 103/105] fix: add container health check with startPeriod to survive slow startup The app takes ~25s to start. Without a container health check, ECS marks the container healthy immediately (PID 1 running) and ELB begins counting health check failures during startup. After 2 failures the task is killed. Adding a container-level health check with startPeriod=60 ensures ECS waits for the app to be genuinely ready before registering it with the target group, so ELB health checks only begin after /health returns 200. Co-Authored-By: Claude Sonnet 4.6 --- terragrunt/aws/ecs/ecs.tf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/terragrunt/aws/ecs/ecs.tf b/terragrunt/aws/ecs/ecs.tf index 31c7560e..6e13c297 100644 --- a/terragrunt/aws/ecs/ecs.tf +++ b/terragrunt/aws/ecs/ecs.tf @@ -69,6 +69,16 @@ module "feedback_viewer" { value = "true" } ] + # Container health check - ECS won't mark the container healthy until /health returns 0. + # startPeriod gives the JVM/Spring 60s to boot before failures are counted. + container_health_check = { + command = ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"] + interval = 15 + timeout = 5 + retries = 3 + startPeriod = 60 + } + container_linux_parameters = {} container_ulimits = [ { From 697991225a80f235c3fed8ece7dc5029e805c90c Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 11 Mar 2026 12:58:59 -0400 Subject: [PATCH 104/105] fix: add missing spring-boot-starter-actuator dependency The /health endpoint was returning 404 because the actuator jar was never included in the build. All the management.* config and ECS env vars were already correct - just the dependency was missing. Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pom.xml b/pom.xml index e19e922e..a434b8e5 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,10 @@ org.springframework.boot spring-boot-starter + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-data-mongodb From 4285e555c4d39983731cdbd8d91af9bd9a3aab4e Mon Sep 17 00:00:00 2001 From: Hamza Aburaneh Date: Wed, 8 Apr 2026 13:48:17 -0400 Subject: [PATCH 105/105] chore: remove HackerOne security.txt ALB listener rule The HackerOne pilot has ended. Remove the aws_alb_listener_rule that served /.well-known/security.txt as a fixed response from the ALB. --- terragrunt/aws/load_balancer/load_balancer.tf | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/terragrunt/aws/load_balancer/load_balancer.tf b/terragrunt/aws/load_balancer/load_balancer.tf index 198c0911..d277d699 100644 --- a/terragrunt/aws/load_balancer/load_balancer.tf +++ b/terragrunt/aws/load_balancer/load_balancer.tf @@ -42,38 +42,6 @@ resource "aws_lb_listener" "feedback_viewer_listener" { }) } -# Serve security.txt as a fixed response from the ALB -resource "aws_alb_listener_rule" "security_txt" { - listener_arn = aws_lb_listener.feedback_viewer_listener.arn - priority = 1 - - action { - type = "fixed-response" - - fixed_response { - content_type = "text/plain" - message_body = <<-EOT - Contact: mailto:ZZTBSCYBERS@tbs-sct.gc.ca - Contact: https://hackerone.com/tbs-sct/ - Canonical: https://${replace(var.domain, "/^[^.]+\\./", "")}/.well-known/security.txt - Expires: 2026-03-29T23:59:59.000Z - Preferred-Languages: en, fr - EOT - status_code = "200" - } - } - - condition { - path_pattern { - values = ["/.well-known/security.txt"] - } - } - - tags = merge(var.default_tags, { - CostCentre = var.billing_code - }) -} - resource "aws_lb_target_group" "feedback_viewer" { name = var.product_name port = 3001