├── .diagrams └── architecture │ └── arch.drawio.svg ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── custom.md │ ├── decision.md │ ├── documentation.md │ ├── epic.md │ ├── feature.md │ ├── question.md │ ├── task.md │ └── ux.md ├── codeowners ├── graphics │ ├── analysis.png │ ├── branch-code-results.png │ ├── branch-protection.png │ ├── demo-label.png │ ├── demo-workflow.png │ ├── deploymentUpdate.png │ ├── merge.png │ ├── mergeNotification.png │ ├── packages.png │ ├── pr-cleanup.png │ ├── pr-close.png │ ├── pr-open.png │ ├── pr-validate.png │ ├── scheduled.png │ ├── schemaspy.png │ └── template.png ├── pull_request_template.md └── workflows │ ├── .deployer.yml │ ├── .tests.yml │ ├── analysis.yml │ ├── demo.yml │ ├── merge.yml │ ├── notifications.yml │ ├── pr-close.yml │ ├── pr-open.yml │ ├── pr-validate.yml │ └── scheduled.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── COMPLIANCE.yaml ├── CONTRIBUTING.md ├── HOWTO.md ├── LICENSE ├── README.md ├── SECURITY.md ├── backend ├── .dockerignore ├── Dockerfile ├── nest-cli.json ├── package-lock.json ├── package.json ├── prisma │ └── schema.prisma ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── app.spec.ts │ ├── app.ts │ ├── common │ │ ├── logger.config.spec.ts │ │ └── logger.config.ts │ ├── health.controller.ts │ ├── main.ts │ ├── metrics.controller.ts │ ├── middleware │ │ ├── prom.ts │ │ ├── req.res.logger.spec.ts │ │ └── req.res.logger.ts │ ├── prisma.module.ts │ ├── prisma.service.spec.ts │ ├── prisma.service.ts │ └── users │ │ ├── dto │ │ ├── create-user.dto.ts │ │ ├── update-user.dto.ts │ │ └── user.dto.ts │ │ ├── users.controller.spec.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ ├── users.service.spec.ts │ │ └── users.service.ts ├── test │ └── app.e2e-spec.ts ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.mts ├── charts ├── app │ ├── .helmignore │ ├── Chart.lock │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── templates │ │ ├── _helpers.tpl │ │ ├── backend │ │ │ └── templates │ │ │ │ ├── _helpers.tpl │ │ │ │ ├── deployment.yaml │ │ │ │ ├── hpa.yaml │ │ │ │ ├── pdb.yaml │ │ │ │ └── service.yaml │ │ ├── frontend │ │ │ └── templates │ │ │ │ ├── _helpers.tpl │ │ │ │ ├── deployment.yaml │ │ │ │ ├── hpa.yaml │ │ │ │ ├── ingress.yaml │ │ │ │ ├── pdb.yaml │ │ │ │ └── service.yaml │ │ ├── knp.yaml │ │ └── secret.yaml │ └── values.yaml └── crunchy │ └── values.yml ├── docker-compose.yml ├── frontend ├── .dockerignore ├── .eslintignore ├── .eslintrc.yml ├── .prettierrc.yml ├── .vscode │ └── extensions.json ├── Caddyfile ├── Dockerfile ├── e2e │ ├── pages │ │ └── dashboard.ts │ ├── qsos.spec.ts │ └── utils │ │ └── index.ts ├── index.html ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public │ └── favicon.ico ├── src │ ├── __tests__ │ │ └── Dashboard.tsx │ ├── assets │ │ ├── BCID_H_rgb_pos.png │ │ └── gov-bc-logo-horiz.png │ ├── components │ │ ├── Dashboard.tsx │ │ ├── Layout.tsx │ │ ├── NotFound.tsx │ │ └── __tests__ │ │ │ ├── Dashboard.test.tsx │ │ │ └── NotFound.test.tsx │ ├── index.css │ ├── interfaces │ │ └── UserDto.ts │ ├── main.tsx │ ├── routeTree.gen.ts │ ├── routes │ │ ├── __root.tsx │ │ └── index.tsx │ ├── scss │ │ └── styles.scss │ ├── service │ │ └── api-service.ts │ ├── test-setup.ts │ └── test-utils.tsx ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts ├── migrations ├── .dockerignore ├── Dockerfile └── sql │ ├── V1.0.0__init.sql │ └── V1.0.1__alter_user_seq.sql ├── renovate.json ├── tests ├── integration │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── main.js │ │ └── test_suites │ │ ├── it.backend.fastapi.json │ │ ├── it.backend.fiber.json │ │ ├── it.backend.nest.json │ │ └── it.backend.quarkus.json └── load │ ├── README.md │ ├── backend-test.js │ └── frontend-test.js └── transfer ├── .github └── workflows │ └── project-sync.yml ├── repos.yaml └── scripts └── sync-to-project.js /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the Bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected Behaviour** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Actual Behaviour** 17 | A clear and concise description of what you expected to happen. 18 | 19 | ** Steps To Reproduce** 20 | Steps to reproduce the behaviour: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: [e.g. iOS] 31 | - Browser [e.g. chrome, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/decision.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Decision 3 | about: This is a big decision that has been made or raised to PO 4 | title: '' 5 | labels: decision 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Documentation for a specific area or need 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | **As a** *(User Type/Persona)* **I want** *(Feature/enhancement)* **So That** *(Value, why is this wanted, what is the user trying to accomplish)* 11 | 12 | **Additional Context** 13 | - enter text here 14 | - enter text here 15 | 16 | **Acceptance Criteria** 17 | - [ ] Given (Context), When (action carried out), Then (expected outcome) 18 | - [ ] Given (Context), When (action carried out), Then (expected outcome) 19 | 20 | **Definition of Done** 21 | - [ ] Ready to Demo in Sprint Review 22 | - [ ] Does what I have made have appropriate test coverage? 23 | - [ ] Documentation and/or scientific documentation exists and can be found 24 | - [ ] Peer Reviewed by 2 people on the team 25 | - [ ] Manual testing of all PRs in Dev and Prod 26 | - [ ] Merged 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/epic.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Epic 3 | about: A User Story Large enough that it cannot be completed in a single sprint, the 4 | desired end state of a feature 5 | title: '' 6 | labels: epic 7 | assignees: '' 8 | 9 | --- 10 | 11 | **As a** *(User Type/Persona)* **I want** *(Feature/enhancement)* **So That** *(Value, why is this wanted, what is the user trying to accomplish)* 12 | 13 | **Additional Context** 14 | 15 | - enter text here 16 | - enter text here 17 | 18 | **Acceptance Criteria** 19 | 20 | - [ ] Given (Context), When (action carried out), Then (expected outcome) 21 | - [ ] Given (Context), When (action carried out), Then (expected outcome) 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request / user story 3 | about: Suggest an idea from the perspective of a user 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **As a** *(User Type/Persona)* **I want** *(Feature/enhancement)* **So That** *(Value, why is this wanted, what is the user trying to accomplish)* 11 | 12 | **Additional Context** 13 | - enter text here 14 | - enter text here 15 | 16 | **Acceptance Criteria** 17 | - [ ] Given (Context), When (action carried out), Then (expected outcome) 18 | - [ ] Given (Context), When (action carried out), Then (expected outcome) 19 | 20 | **Definition of Done** 21 | - [ ] Ready to Demo in Sprint Review 22 | - [ ] Does what I have made have appropriate test coverage? 23 | - [ ] Documentation and/or scientific documentation exists and can be found 24 | - [ ] Peer Reviewed by 2 people on the team 25 | - [ ] Manual testing of all PRs in Dev and Prod 26 | - [ ] Merged 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask us a question! 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the task** 11 | basic description of the task, is it focuse on research with users or the business area? is it design focused on either co-design or wireframing? is it User Testing or compiling results? 12 | 13 | **Acceptance Criteria** 14 | - [ ] what is required for this task to be complete? 15 | - what is the finishing point or end state of this task? 16 | - [ ] what is the output of this task? 17 | 18 | **SME/User Contact** 19 | (may want to use a persona to fill this in) 20 | 21 | **Additional context** 22 | - any additional details that could not be captured above 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Task 3 | about: Work for the team that cannot be written as a user story 4 | title: '' 5 | labels: task 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the task** 11 | A clear and concise description of what the task is. 12 | 13 | **Acceptance Criteria** 14 | - [ ] first 15 | - [ ] second 16 | - [ ] third 17 | 18 | **Additional context** 19 | - Add any other context about the task here. 20 | - Or here 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ux.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: UX Task 3 | about: This is a Task for UX Research, Design or Testing 4 | title: '' 5 | labels: ux 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the task** 11 | basic description of the task, is it focuse on research with users or the business area? is it design focused on either co-design or wireframing? is it User Testing or compiling results? 12 | 13 | **Acceptance Criteria** 14 | - [ ] what is required for this task to be complete? 15 | - what is the finishing point or end state of this task? 16 | - [ ] what is the output of this task? 17 | 18 | **SME/User Contact** 19 | (may want to use a persona to fill this in) 20 | 21 | **Additional context** 22 | - any additional details that could not be captured above 23 | -------------------------------------------------------------------------------- /.github/codeowners: -------------------------------------------------------------------------------- 1 | # Matched against repo root (asterisk) 2 | # * @mishraomp @DerekRoberts 3 | 4 | # Matched against directories 5 | # /.github/workflows/ @mishraomp @DerekRoberts 6 | 7 | # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 8 | -------------------------------------------------------------------------------- /.github/graphics/analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/analysis.png -------------------------------------------------------------------------------- /.github/graphics/branch-code-results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/branch-code-results.png -------------------------------------------------------------------------------- /.github/graphics/branch-protection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/branch-protection.png -------------------------------------------------------------------------------- /.github/graphics/demo-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/demo-label.png -------------------------------------------------------------------------------- /.github/graphics/demo-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/demo-workflow.png -------------------------------------------------------------------------------- /.github/graphics/deploymentUpdate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/deploymentUpdate.png -------------------------------------------------------------------------------- /.github/graphics/merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/merge.png -------------------------------------------------------------------------------- /.github/graphics/mergeNotification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/mergeNotification.png -------------------------------------------------------------------------------- /.github/graphics/packages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/packages.png -------------------------------------------------------------------------------- /.github/graphics/pr-cleanup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/pr-cleanup.png -------------------------------------------------------------------------------- /.github/graphics/pr-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/pr-close.png -------------------------------------------------------------------------------- /.github/graphics/pr-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/pr-open.png -------------------------------------------------------------------------------- /.github/graphics/pr-validate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/pr-validate.png -------------------------------------------------------------------------------- /.github/graphics/scheduled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/scheduled.png -------------------------------------------------------------------------------- /.github/graphics/schemaspy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/schemaspy.png -------------------------------------------------------------------------------- /.github/graphics/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/.github/graphics/template.png -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Description 4 | 5 | Please provide a summary of the change and the issue fixed. Please include relevant context. List dependency changes. 6 | 7 | Fixes # (issue) 8 | 9 | ## Type of change 10 | 11 | Please delete options that are not relevant. 12 | 13 | - [ ] Bug fix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | - [ ] This change requires a documentation update 17 | 18 | # How Has This Been Tested? 19 | 20 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 21 | 22 | - [ ] Test A 23 | - [ ] Test B 24 | 25 | 26 | ## Checklist 27 | 28 | 29 | 30 | 31 | - [ ] I have read the [CONTRIBUTING](CONTRIBUTING.md) doc 32 | - [ ] I have performed a self-review of my own code 33 | - [ ] I have commented my code, particularly in hard-to-understand areas 34 | - [ ] I have made corresponding changes to the documentation 35 | - [ ] My changes generate no new warnings 36 | - [ ] I have added tests that prove my fix is effective or that my feature works 37 | - [ ] New and existing unit tests pass locally with my changes 38 | - [ ] Any dependent changes have already been accepted and merged 39 | 40 | 41 | ## Further comments 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/.deployer.yml: -------------------------------------------------------------------------------- 1 | name: .Helm Deployer 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ### Required 7 | # Only secrets! 8 | 9 | ### Typical / recommended 10 | atomic: 11 | description: Atomic deployment? That means fail all or nothing 12 | default: false 13 | required: false 14 | type: boolean 15 | directory: 16 | description: Chart directory 17 | default: 'charts/app' 18 | required: false 19 | type: string 20 | environment: 21 | description: Environment name; omit for PRs 22 | required: false 23 | type: string 24 | oc_server: 25 | default: https://api.silver.devops.gov.bc.ca:6443 26 | description: OpenShift server 27 | required: false 28 | type: string 29 | params: 30 | description: Extra parameters to pass to helm upgrade 31 | required: false 32 | type: string 33 | tag: 34 | description: Specify a tag to deploy; defaults to PR number 35 | required: false 36 | type: string 37 | triggers: 38 | description: Paths used to trigger a deployment; e.g. ('./backend/' './frontend/) 39 | required: false 40 | type: string 41 | db_user: 42 | description: The database user 43 | required: false 44 | default: 'app' 45 | type: string 46 | debug: 47 | description: Debug mode 48 | default: false 49 | required: false 50 | type: boolean 51 | 52 | ### Usually a bad idea / not recommended 53 | timeout-minutes: 54 | description: 'Timeout minutes' 55 | default: 10 56 | required: false 57 | type: number 58 | values: 59 | description: 'Values file' 60 | default: 'values.yaml' 61 | required: false 62 | type: string 63 | 64 | outputs: 65 | tag: 66 | description: 'Which tag was used for deployment?' 67 | value: ${{ jobs.deploy.outputs.tag }} 68 | triggered: 69 | description: 'Has a deployment has been triggered?' 70 | value: ${{ jobs.deploy.outputs.triggered }} 71 | 72 | secrets: 73 | oc_namespace: 74 | description: OpenShift namespace 75 | required: true 76 | oc_token: 77 | description: OpenShift token 78 | required: true 79 | 80 | permissions: {} 81 | 82 | jobs: 83 | deploy: 84 | name: Stack 85 | environment: ${{ inputs.environment }} 86 | runs-on: ubuntu-24.04 87 | outputs: 88 | tag: ${{ inputs.tag || steps.pr.outputs.pr }} 89 | triggered: ${{ steps.deploy.outputs.triggered }} 90 | steps: 91 | - uses: bcgov/action-crunchy@ac46670c974c2238fffb635eee7bbd27735c25bc # v1.2.1 92 | name: Deploy Crunchy 93 | id: deploy_crunchy 94 | with: 95 | oc_namespace: ${{ secrets.OC_NAMESPACE }} 96 | oc_token: ${{ secrets.OC_TOKEN }} 97 | environment: ${{ inputs.environment }} 98 | values_file: charts/crunchy/values.yml 99 | triggers: ${{ inputs.triggers }} 100 | 101 | # Variables 102 | - if: inputs.tag == '' 103 | id: pr 104 | uses: bcgov/action-get-pr@21f9351425cd55a98e869ee28919a512aa30647d # v0.0.1 105 | 106 | - id: vars 107 | run: | 108 | # Vars: tag and release 109 | 110 | # Tag defaults to PR number, but can be overridden by inputs.tag 111 | tag=${{ inputs.tag || steps.pr.outputs.pr }} 112 | 113 | # Release name includes run numbers to ensure uniqueness 114 | release=${{ github.event.repository.name }}-${{ inputs.environment || steps.pr.outputs.pr || inputs.tag }} 115 | 116 | # version, to support helm packaging for non-pr based releases (workflow_dispatch). default to 1.0.0+github run number 117 | version=1.0.0+${{ github.run_number }} 118 | 119 | # Summary 120 | echo "tag=${tag}" 121 | echo "release=${release}" 122 | echo "version=${version}" 123 | 124 | # Output 125 | echo "tag=${tag}" >> $GITHUB_OUTPUT 126 | echo "release=${release}" >> $GITHUB_OUTPUT 127 | echo "version=${version}" >> $GITHUB_OUTPUT 128 | 129 | - name: Stop pre-existing deployments on PRs (status = pending-upgrade) 130 | if: github.event_name == 'pull_request' 131 | uses: bcgov/action-oc-runner@12997e908fba505079d1aab6f694a17fe15e9b28 # v1.2.2 132 | with: 133 | oc_namespace: ${{ secrets.oc_namespace }} 134 | oc_token: ${{ secrets.oc_token }} 135 | oc_server: ${{ vars.oc_server }} 136 | triggers: ${{ inputs.triggers }} 137 | commands: | 138 | # Interrupt any previous deployments (PR only) 139 | PREVIOUS=$(helm status ${{ steps.vars.outputs.release }} -o json | jq .info.status || true) 140 | if [[ ${PREVIOUS} =~ pending ]]; then 141 | echo "Rollback triggered" 142 | helm rollback ${{ steps.vars.outputs.release }} || \ 143 | helm uninstall ${{ steps.vars.outputs.release }} 144 | fi 145 | 146 | - uses: actions/checkout@v4 147 | - name: Debug Values File 148 | if: inputs.debug == 'true' 149 | run: ls -l charts/crunchy/values.yml 150 | 151 | - name: Helm Deploy 152 | id: deploy 153 | uses: bcgov/action-oc-runner@12997e908fba505079d1aab6f694a17fe15e9b28 # v1.2.2 154 | with: 155 | oc_namespace: ${{ secrets.oc_namespace }} 156 | oc_token: ${{ secrets.oc_token }} 157 | oc_server: ${{ vars.oc_server }} 158 | triggers: ${{ inputs.triggers }} 159 | ref: ${{ github.ref }} 160 | commands: | 161 | # Deploy 162 | 163 | # If directory provided, cd to it 164 | [ -z "${{ inputs.directory }}" ]|| cd ${{ inputs.directory }} 165 | 166 | # Helm package 167 | sed -i 's/^name:.*/name: ${{ github.event.repository.name }}/' Chart.yaml 168 | helm package -u . --app-version="tag-${{ steps.vars.outputs.tag }}_run-${{ github.run_number }}" --version=${{ steps.pr.outputs.pr || steps.vars.outputs.version }} 169 | # print the values.yaml file to see the values being used 170 | # Helm upgrade/rollout 171 | helm upgrade \ 172 | --set-string global.repository=${{ github.repository }} \ 173 | --set-string global.tag="${{ steps.vars.outputs.tag }}" \ 174 | --set-string global.config.databaseUser="${{ inputs.db_user }}" \ 175 | --set-string global.databaseAlias="${{ steps.deploy_crunchy.outputs.release }}-crunchy" \ 176 | ${{ inputs.params }} \ 177 | --install --wait ${{ inputs.atomic && '--atomic' || '' }} ${{ steps.vars.outputs.release }} \ 178 | --timeout ${{ inputs.timeout-minutes }}m \ 179 | --values ${{ inputs.values }} \ 180 | ./${{ github.event.repository.name }}-${{ steps.pr.outputs.pr || steps.vars.outputs.version }}.tgz 181 | 182 | # Helm release history 183 | helm history ${{ steps.vars.outputs.release }} 184 | 185 | # Completed pod cleanup 186 | oc delete po --field-selector=status.phase==Succeeded || true 187 | -------------------------------------------------------------------------------- /.github/workflows/.tests.yml: -------------------------------------------------------------------------------- 1 | name: .Tests 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ### Required 7 | target: 8 | description: PR number, test or prod 9 | default: ${{ github.event.number }} 10 | type: string 11 | 12 | env: 13 | DOMAIN: apps.silver.devops.gov.bc.ca 14 | PREFIX: ${{ github.event.repository.name }}-${{ inputs.target }} 15 | 16 | 17 | permissions: {} 18 | 19 | jobs: 20 | integration-tests: 21 | name: Integration 22 | runs-on: ubuntu-24.04 23 | timeout-minutes: 1 24 | steps: 25 | - uses: actions/checkout@v4 26 | - id: cache-npm 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.os }}-build-cache-node-modules-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-build-cache-node-modules- 33 | ${{ runner.os }}-build- 34 | ${{ runner.os }}- 35 | 36 | - env: 37 | API_NAME: nest 38 | BASE_URL: https://${{ env.PREFIX }}-frontend.${{ env.DOMAIN }} 39 | run: | 40 | cd tests/integration 41 | npm ci 42 | node src/main.js 43 | 44 | e2e-tests: 45 | name: E2E 46 | defaults: 47 | run: 48 | working-directory: frontend 49 | runs-on: ubuntu-24.04 50 | timeout-minutes: 5 51 | strategy: 52 | matrix: 53 | project: [ chromium, Google Chrome, firefox, safari, Microsoft Edge ] 54 | steps: 55 | - uses: actions/checkout@v4 56 | name: Checkout 57 | - uses: actions/setup-node@v4 58 | name: Setup Node 59 | with: 60 | node-version: 20 61 | cache: 'npm' 62 | cache-dependency-path: frontend/package-lock.json 63 | - name: Install dependencies 64 | run: | 65 | npm ci 66 | npx playwright install --with-deps 67 | 68 | - name: Run Tests 69 | env: 70 | E2E_BASE_URL: https://${{ github.event.repository.name }}-${{ inputs.target }}-frontend.${{ env.DOMAIN }}/ 71 | CI: 'true' 72 | run: | 73 | npx playwright test --project="${{ matrix.project }}" --reporter=html 74 | 75 | - uses: actions/upload-artifact@v4 76 | if: (! cancelled()) 77 | name: upload results 78 | with: 79 | name: playwright-report-${{ matrix.project }} 80 | path: "./frontend/playwright-report" # path from current folder 81 | retention-days: 7 82 | 83 | load-tests: 84 | name: Load 85 | runs-on: ubuntu-24.04 86 | strategy: 87 | matrix: 88 | name: [backend, frontend] 89 | steps: 90 | - uses: actions/checkout@v4 91 | - uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0 92 | - uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0 93 | env: 94 | BACKEND_URL: https://${{ env.PREFIX }}-frontend.${{ env.DOMAIN }}/api 95 | FRONTEND_URL: https://${{ env.PREFIX }}-frontend.${{ env.DOMAIN }} 96 | with: 97 | path: ./tests/load/${{ matrix.name }}-test.js 98 | flags: --vus 100 --duration 300s 99 | -------------------------------------------------------------------------------- /.github/workflows/analysis.yml: -------------------------------------------------------------------------------- 1 | name: Analysis 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] 8 | schedule: 9 | - cron: "0 11 * * 0" # 3 AM PST = 12 PM UDT, runs sundays 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | permissions: {} 17 | 18 | jobs: 19 | backend-tests: 20 | name: Backend Tests 21 | if: (! github.event.pull_request.draft) 22 | runs-on: ubuntu-24.04 23 | timeout-minutes: 5 24 | services: 25 | postgres: 26 | image: postgres 27 | env: 28 | POSTGRES_PASSWORD: default 29 | options: >- 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | ports: 35 | - 5432:5432 36 | steps: 37 | - uses: bcgov/action-test-and-analyse@e2ba34132662c1638dbde806064eb7004b3761c3 # v1.3.0 38 | env: 39 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} 40 | with: 41 | commands: | 42 | npm ci 43 | npm run test:cov 44 | dir: backend 45 | node_version: "22" 46 | sonar_args: > 47 | -Dsonar.exclusions=**/coverage/**,**/node_modules/**,**/*spec.ts 48 | -Dsonar.organization=bcgov-sonarcloud 49 | -Dsonar.projectKey=quickstart-openshift_backend 50 | -Dsonar.sources=src 51 | -Dsonar.tests.inclusions=**/*spec.ts 52 | -Dsonar.javascript.lcov.reportPaths=./coverage/lcov.info 53 | sonar_token: ${{ env.SONAR_TOKEN }} 54 | triggers: ('backend/') 55 | 56 | frontend-tests: 57 | name: Frontend Tests 58 | if: (! github.event.pull_request.draft) 59 | runs-on: ubuntu-24.04 60 | timeout-minutes: 5 61 | steps: 62 | - uses: bcgov/action-test-and-analyse@e2ba34132662c1638dbde806064eb7004b3761c3 # v1.3.0 63 | env: 64 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} 65 | with: 66 | commands: | 67 | npm ci 68 | npm run test:cov 69 | dir: frontend 70 | node_version: "22" 71 | sonar_args: > 72 | -Dsonar.exclusions=**/coverage/**,**/node_modules/**,**/*spec.ts 73 | -Dsonar.organization=bcgov-sonarcloud 74 | -Dsonar.projectKey=quickstart-openshift_frontend 75 | -Dsonar.sources=src 76 | -Dsonar.tests.inclusions=**/*spec.ts 77 | -Dsonar.javascript.lcov.reportPaths=./coverage/lcov.info 78 | sonar_token: ${{ env.SONAR_TOKEN }} 79 | triggers: ('frontend/') 80 | 81 | # https://github.com/marketplace/actions/aqua-security-trivy 82 | trivy: 83 | name: Trivy Security Scan 84 | if: (! github.event.pull_request.draft) 85 | continue-on-error: true 86 | permissions: 87 | security-events: write 88 | runs-on: ubuntu-24.04 89 | timeout-minutes: 1 90 | steps: 91 | - uses: actions/checkout@v4 92 | - name: Run Trivy vulnerability scanner in repo mode 93 | uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # 0.31.0 94 | with: 95 | format: "sarif" 96 | output: "trivy-results.sarif" 97 | ignore-unfixed: true 98 | scan-type: "fs" 99 | scanners: "vuln,secret,config" 100 | severity: "CRITICAL,HIGH" 101 | 102 | - name: Upload Trivy scan results to GitHub Security tab 103 | uses: github/codeql-action/upload-sarif@v3 104 | with: 105 | sarif_file: "trivy-results.sarif" 106 | 107 | results: 108 | name: Analysis Results 109 | needs: [backend-tests, frontend-tests] 110 | if: (! github.event.pull_request.draft) 111 | runs-on: ubuntu-24.04 112 | steps: 113 | - if: contains(needs.*.result, 'failure')||contains(needs.*.result, 'canceled') 114 | run: echo "At least one job has failed." && exit 1 115 | - run: echo "Success!" 116 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: DEMO Route 2 | 3 | on: 4 | pull_request: 5 | types: [labeled] 6 | workflow_dispatch: 7 | inputs: 8 | target: 9 | description: 'PR number to receive DEMO URL routing' 10 | required: true 11 | type: number 12 | 13 | concurrency: 14 | group: ${{ github.workflow }} 15 | cancel-in-progress: true 16 | 17 | permissions: {} 18 | 19 | jobs: 20 | demo-routing: 21 | name: DEMO Routing 22 | if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'demo' 23 | env: 24 | DEST: demo 25 | DOMAIN: apps.silver.devops.gov.bc.ca 26 | REPO: ${{ github.event.repository.name }} 27 | runs-on: ubuntu-24.04 28 | steps: 29 | - name: Point DEMO URL to Existing Service 30 | uses: bcgov/action-oc-runner@12997e908fba505079d1aab6f694a17fe15e9b28 # v1.2.2 31 | with: 32 | oc_namespace: ${{ secrets.oc_namespace }} 33 | oc_token: ${{ secrets.oc_token }} 34 | oc_server: ${{ vars.oc_server }} 35 | command: | 36 | oc delete route/${{ env.REPO }}-${{ env.DEST }} --ignore-not-found=true 37 | oc create route edge ${{ env.REPO }}-${{ env.DEST }} \ 38 | --hostname=${{ env.REPO }}-${{ env.DEST }}.${{ env.DOMAIN }} \ 39 | --service=${{ env.REPO }}-${{ github.event.number || inputs.target }}-frontend 40 | -------------------------------------------------------------------------------- /.github/workflows/merge.yml: -------------------------------------------------------------------------------- 1 | name: Merge 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - '*.md' 8 | - '.github/**' 9 | - '.github/graphics/**' 10 | - '!.github/workflows/**' 11 | workflow_dispatch: 12 | inputs: 13 | tag: 14 | description: "Image tag set to deploy; e.g. PR number or prod" 15 | type: string 16 | default: 'prod' 17 | 18 | concurrency: 19 | # Do not interrupt previous workflows 20 | group: ${{ github.workflow }} 21 | cancel-in-progress: false 22 | 23 | permissions: {} 24 | 25 | jobs: 26 | # https://github.com/bcgov/quickstart-openshift-helpers 27 | deploy-test: 28 | name: Deploy (TEST) 29 | uses: ./.github/workflows/.deployer.yml 30 | secrets: inherit 31 | with: 32 | environment: test 33 | db_user: app 34 | tag: ${{ inputs.tag }} 35 | 36 | tests: 37 | name: Tests 38 | needs: [deploy-test] 39 | uses: ./.github/workflows/.tests.yml 40 | with: 41 | target: test 42 | 43 | deploy-prod: 44 | name: Deploy (PROD) 45 | needs: [tests] 46 | uses: ./.github/workflows/.deployer.yml 47 | secrets: inherit 48 | with: 49 | environment: prod 50 | db_user: app 51 | params: 52 | --set backend.deploymentStrategy=RollingUpdate 53 | --set frontend.deploymentStrategy=RollingUpdate 54 | --set global.autoscaling=true 55 | --set frontend.pdb.enabled=true 56 | --set backend.pdb.enabled=true 57 | tag: ${{ inputs.tag }} 58 | 59 | promote: 60 | name: Promote Images 61 | needs: [deploy-prod] 62 | runs-on: ubuntu-24.04 63 | permissions: 64 | packages: write 65 | strategy: 66 | matrix: 67 | package: [migrations, backend, frontend] 68 | timeout-minutes: 1 69 | steps: 70 | - uses: shrink/actions-docker-registry-tag@f04afd0559f66b288586792eb150f45136a927fa # v4 71 | with: 72 | registry: ghcr.io 73 | repository: ${{ github.repository }}/${{ matrix.package }} 74 | target: ${{ needs.deploy-prod.outputs.tag }} 75 | tags: prod 76 | -------------------------------------------------------------------------------- /.github/workflows/notifications.yml: -------------------------------------------------------------------------------- 1 | name: Notifications 2 | on: 3 | workflow_run: 4 | workflows: [PR, Merge] 5 | types: [completed] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }} 9 | cancel-in-progress: true 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | troubleshoot: 15 | runs-on: ubuntu-24.04 16 | steps: 17 | - run: echo "${{ vars.MSTEAMS_WEBHOOK }}" 18 | 19 | notify-teams-pr: 20 | if: github.event.workflow_run.event == 'pull_request' && vars.MSTEAMS_WEBHOOK != null 21 | runs-on: ubuntu-24.04 22 | steps: 23 | - uses: simbo/msteams-message-card-action@d87ad6c3908b72f4fd94b55d937d05395c7300dc # v1.4.3 24 | if: vars.MSTEAMS_WEBHOOK != null 25 | with: 26 | webhook: ${{ vars.MSTEAMS_WEBHOOK }} 27 | title: "${{github.event.workflow_run.head_commit.message}}" 28 | message: "${{github.event.workflow_run.head_commit.message}}" 29 | color: 'dodger blue' 30 | buttons: | 31 | Pull Request ${{github.event.workflow_run.pull_requests[0].number}} ${{github.event.workflow_run.repository.html_url}}/pull/${{github.event.workflow_run.pull_requests[0].number}} 32 | Diff ${{github.event.workflow_run.repository.html_url}}/pull/${{github.event.workflow_run.pull_requests[0].number}}/files 33 | sections: | 34 | - 35 | activity: 36 | title: ${{github.event.workflow_run.head_commit.committer.name}} 37 | subtitle: ${{github.event.workflow_run.head_commit.timestamp}} 38 | image: ${{github.event.workflow_run.head_repository.owner.avatar_url}} 39 | text: PR Opened 40 | notify-teams-merged: 41 | if: github.event.workflow_run.event == 'push' && vars.MSTEAMS_WEBHOOK != null 42 | runs-on: ubuntu-24.04 43 | steps: 44 | - name: PR Number 45 | if: vars.MSTEAMS_WEBHOOK != null 46 | id: pr 47 | shell: bash 48 | run: | 49 | pr=$(\ 50 | curl -sL -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${{ github.token }}" \ 51 | -H "X-GitHub-Api-Version: 2022-11-28" \ 52 | https://api.github.com/repos/${{ github.repository }}/commits/${{ github.event.workflow_run.head_sha }}/pulls \ 53 | | jq .[0].number 54 | ) 55 | if [ -z "${pr}" ] 56 | then 57 | echo "No PR number found. Was this push triggered by a squashed PR merge?" 58 | pr="" 59 | fi 60 | echo "pr=${pr}" >> $GITHUB_OUTPUT 61 | - uses: simbo/msteams-message-card-action@d87ad6c3908b72f4fd94b55d937d05395c7300dc # v1.4.3 62 | if: vars.MSTEAMS_WEBHOOK 63 | with: 64 | webhook: ${{ vars.MSTEAMS_WEBHOOK }} 65 | title: "${{github.event.workflow_run.head_commit.message}}" 66 | message: "${{github.event.workflow_run.head_commit.message}}" 67 | color: 'dark orange' 68 | summary: "${{github.event.workflow_run.event}}-${{github.event.workflow_run.status}}" 69 | buttons: | 70 | Pull Request ${{steps.pr.outputs.pr}} ${{github.event.workflow_run.repository.html_url}}/pull/${{steps.pr.outputs.pr}} 71 | Diff ${{github.event.workflow_run.repository.html_url}}/pull/${{steps.pr.outputs.pr}}/files 72 | sections: | 73 | - 74 | activity: 75 | title: ${{github.event.workflow_run.head_commit.committer.name}} 76 | subtitle: ${{github.event.workflow_run.head_commit.timestamp}} 77 | image: ${{github.event.workflow_run.head_repository.owner.avatar_url}} 78 | text: Merged 79 | -------------------------------------------------------------------------------- /.github/workflows/pr-close.yml: -------------------------------------------------------------------------------- 1 | name: PR Closed 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | 7 | concurrency: 8 | # PR open and close use the same group, allowing only one at a time 9 | group: ${{ github.event.number }} 10 | cancel-in-progress: true 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | cleanup: 16 | name: Cleanup and Image Promotion 17 | uses: bcgov/quickstart-openshift-helpers/.github/workflows/.pr-close.yml@0b8121a528aaa05ef8def2f79be9081691dfe98a # v0.9.0 18 | permissions: 19 | packages: write 20 | secrets: 21 | oc_namespace: ${{ secrets.OC_NAMESPACE }} 22 | oc_token: ${{ secrets.OC_TOKEN }} 23 | with: 24 | cleanup: helm 25 | packages: backend frontend migrations 26 | 27 | cleanup_db: # TODO move it off to another action later. 28 | name: Remove DB User from Crunchy 29 | runs-on: ubuntu-24.04 30 | steps: 31 | - uses: bcgov/action-crunchy@ac46670c974c2238fffb635eee7bbd27735c25bc # v1.2.1 32 | name: Remove PR Specific User 33 | with: 34 | oc_namespace: ${{ secrets.oc_namespace }} 35 | oc_token: ${{ secrets.oc_token }} 36 | oc_server: ${{ vars.oc_server }} 37 | values_file: charts/crunchy/values.yml 38 | -------------------------------------------------------------------------------- /.github/workflows/pr-open.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | # Cancel in progress for PR open and close 8 | group: ${{ github.event.number }} 9 | cancel-in-progress: true 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | # https://github.com/bcgov/action-builder-ghcr 15 | builds: 16 | name: Builds 17 | permissions: 18 | packages: write 19 | runs-on: ubuntu-24.04 20 | strategy: 21 | matrix: 22 | package: [backend, frontend, migrations] 23 | timeout-minutes: 10 24 | steps: 25 | - uses: bcgov/action-builder-ghcr@ec30e4ce1ac3c25c93ec26cf370ecba028dc478e # v3.0.1 26 | with: 27 | package: ${{ matrix.package }} 28 | tags: ${{ github.event.number }} 29 | tag_fallback: latest 30 | triggers: ('${{ matrix.package }}/') 31 | 32 | # https://github.com/bcgov/quickstart-openshift-helpers 33 | deploys: 34 | name: Deploys (${{ github.event.number }}) 35 | needs: [builds] 36 | uses: ./.github/workflows/.deployer.yml 37 | secrets: 38 | oc_namespace: ${{ secrets.OC_NAMESPACE }} 39 | oc_token: ${{ secrets.OC_TOKEN }} 40 | with: 41 | db_user: app-${{ github.event.number }} 42 | params: --set global.secrets.persist=false 43 | triggers: ('backend/' 'frontend/' 'migrations/' 'charts/' '.github/workflows/.deployer.yml') 44 | 45 | tests: 46 | name: Tests 47 | if: needs.deploys.outputs.triggered == 'true' 48 | needs: [deploys] 49 | uses: ./.github/workflows/.tests.yml 50 | 51 | results: 52 | name: PR Results 53 | needs: [builds, deploys, tests] 54 | if: always() 55 | runs-on: ubuntu-24.04 56 | steps: 57 | - if: contains(needs.*.result, 'failure')||contains(needs.*.result, 'canceled') 58 | run: echo "At least one job has failed." && exit 1 59 | - run: echo "Success!" 60 | -------------------------------------------------------------------------------- /.github/workflows/pr-validate.yml: -------------------------------------------------------------------------------- 1 | name: PR Validate 2 | 3 | on: 4 | pull_request: 5 | types: [edited, opened, synchronize, reopened, ready_for_review] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-edit-${{ github.event.number }} 9 | cancel-in-progress: true 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | validate: 15 | name: Validate PR 16 | if: (! github.event.pull_request.draft) 17 | permissions: 18 | pull-requests: write 19 | uses: bcgov/quickstart-openshift-helpers/.github/workflows/.pr-validate.yml@0b8121a528aaa05ef8def2f79be9081691dfe98a # v0.9.0 20 | with: 21 | markdown_links: | 22 | - [Frontend](https://${{ github.event.repository.name }}-${{ github.event.number }}-frontend.apps.silver.devops.gov.bc.ca) 23 | - [Backend](https://${{ github.event.repository.name }}-${{ github.event.number }}-frontend.apps.silver.devops.gov.bc.ca/api) 24 | 25 | results: 26 | name: Validate Results 27 | if: always() 28 | needs: [validate] 29 | runs-on: ubuntu-24.04 30 | steps: 31 | - run: echo "Success!" 32 | -------------------------------------------------------------------------------- /.github/workflows/scheduled.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled 2 | 3 | on: 4 | schedule: [cron: "0 11 * * 6"] # 3 AM PST = 12 PM UDT, Saturdays 5 | workflow_dispatch: 6 | workflow_call: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | ageOutPRs: 16 | name: PR Deployment Purge 17 | env: 18 | # https://tecadmin.net/getting-yesterdays-date-in-bash/ 19 | CUTOFF: "1 week ago" 20 | runs-on: ubuntu-24.04 21 | timeout-minutes: 10 22 | steps: 23 | - name: Clean up Helm Releases 24 | uses: bcgov/action-oc-runner@12997e908fba505079d1aab6f694a17fe15e9b28 # v1.2.2 25 | with: 26 | oc_namespace: ${{ secrets.oc_namespace }} 27 | oc_token: ${{ secrets.oc_token }} 28 | oc_server: ${{ vars.oc_server }} 29 | commands: | 30 | # Catch errors, unset variables, and pipe failures (e.g. grep || true ) 31 | set -euo pipefail 32 | 33 | # Echos 34 | echo "Delete stale Helm releases" 35 | echo "Cutoff: ${{ env.CUTOFF }}" 36 | 37 | # Before date, list of releases 38 | BEFORE=$(date +%s -d "${{ env.CUTOFF }}") 39 | RELEASES=$(helm ls -aq | grep ${{ github.event.repository.name }} || :) 40 | 41 | # If releases, then iterate 42 | [ -z "${RELEASES}" ]|| for r in ${RELEASES[@]}; do 43 | 44 | # Get last update and convert the date 45 | UPDATED=$(date "+%s" -d <<< echo $(helm status $r -o json | jq -r .info.last_deployed)) 46 | 47 | # Compare to cutoff and delete as necessary 48 | if [[ ${UPDATED} < ${BEFORE} ]]; then 49 | echo -e "\nOlder than cutoff: ${r}" 50 | helm uninstall --no-hooks ${r} 51 | oc delete pvc/${r}-bitnami-pg-0 || true 52 | else 53 | echo -e "\nNewer than cutoff: ${r}" 54 | echo "No need to delete" 55 | fi 56 | done 57 | 58 | # https://github.com/bcgov/quickstart-openshift-helpers 59 | schema-spy: 60 | name: SchemaSpy 61 | permissions: 62 | contents: write 63 | uses: bcgov/quickstart-openshift-helpers/.github/workflows/.schema-spy.yml@0b8121a528aaa05ef8def2f79be9081691dfe98a # v0.9.0 64 | 65 | # Run sequentially to reduce chances of rate limiting 66 | zap: 67 | name: ZAP Scans 68 | permissions: 69 | issues: write 70 | runs-on: ubuntu-24.04 71 | strategy: 72 | matrix: 73 | name: [backend, frontend] 74 | include: 75 | - name: backend 76 | path: api 77 | - name: frontend 78 | steps: 79 | - name: ZAP Scan 80 | uses: zaproxy/action-full-scan@75ee1686750ab1511a73b26b77a2aedd295053ed # v0.12.0 81 | with: 82 | allow_issue_writing: true 83 | artifact_name: ${{ matrix.name }} 84 | issue_title: "ZAP Security Report: ${{ matrix.name }}" 85 | token: ${{ secrets.GITHUB_TOKEN }} 86 | target: https://${{ github.event.repository.name }}-test-frontend.apps.silver.devops.gov.bc.ca/${{ matrix.path }} 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Generated ### 2 | # Created by https://www.toptal.com/developers/gitignore/api/node,java,python,go 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,java,python,go 4 | 5 | ### Go ### 6 | # If you prefer the allow list template instead of the deny list, see community template: 7 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 8 | # 9 | # Binaries for programs and plugins 10 | *.exe 11 | *.exe~ 12 | *.dll 13 | *.so 14 | *.dylib 15 | 16 | # Test binary, built with `go test -c` 17 | *.test 18 | 19 | # Output of the go coverage tool, specifically when used with LiteIDE 20 | *.out 21 | 22 | # Dependency directories (remove the comment below to include it) 23 | # vendor/ 24 | 25 | # Go workspace file 26 | go.work 27 | 28 | ### Java ### 29 | # Compiled class file 30 | *.class 31 | 32 | # Log file 33 | *.log 34 | 35 | # BlueJ files 36 | *.ctxt 37 | 38 | # Mobile Tools for Java (J2ME) 39 | .mtj.tmp/ 40 | 41 | # Package Files # 42 | *.jar 43 | *.war 44 | *.nar 45 | *.ear 46 | *.zip 47 | *.tar.gz 48 | *.rar 49 | 50 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 51 | hs_err_pid* 52 | replay_pid* 53 | 54 | ### Node ### 55 | # Logs 56 | logs 57 | npm-debug.log* 58 | yarn-debug.log* 59 | yarn-error.log* 60 | lerna-debug.log* 61 | .pnpm-debug.log* 62 | 63 | # Diagnostic reports (https://nodejs.org/api/report.html) 64 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 65 | 66 | # Runtime data 67 | pids 68 | *.pid 69 | *.seed 70 | *.pid.lock 71 | 72 | # Directory for instrumented libs generated by jscoverage/JSCover 73 | lib-cov 74 | 75 | # Coverage directory used by tools like istanbul 76 | coverage 77 | *.lcov 78 | lcov.* 79 | 80 | # nyc test coverage 81 | .nyc_output 82 | 83 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 84 | .grunt 85 | 86 | # Bower dependency directory (https://bower.io/) 87 | bower_components 88 | 89 | # node-waf configuration 90 | .lock-wscript 91 | 92 | # Compiled binary addons (https://nodejs.org/api/addons.html) 93 | build/Release 94 | 95 | # Dependency directories 96 | node_modules/ 97 | jspm_packages/ 98 | 99 | # Snowpack dependency directory (https://snowpack.dev/) 100 | web_modules/ 101 | 102 | # TypeScript cache 103 | *.tsbuildinfo 104 | 105 | # Optional npm cache directory 106 | .npm 107 | 108 | # Optional eslint cache 109 | .eslintcache 110 | 111 | # Optional stylelint cache 112 | .stylelintcache 113 | 114 | # Microbundle cache 115 | .rpt2_cache/ 116 | .rts2_cache_cjs/ 117 | .rts2_cache_es/ 118 | .rts2_cache_umd/ 119 | 120 | # Optional REPL history 121 | .node_repl_history 122 | 123 | # Output of 'npm pack' 124 | *.tgz 125 | 126 | # Yarn Integrity file 127 | .yarn-integrity 128 | 129 | # dotenv environment variable files 130 | .env 131 | .env.development.local 132 | .env.test.local 133 | .env.production.local 134 | .env.local 135 | 136 | # parcel-bundler cache (https://parceljs.org/) 137 | .cache 138 | .parcel-cache 139 | 140 | # Next.js build output 141 | .next 142 | out 143 | 144 | # Nuxt.js build / generate output 145 | .nuxt 146 | dist 147 | 148 | # Gatsby files 149 | .cache/ 150 | # Comment in the public line in if your project uses Gatsby and not Next.js 151 | # https://nextjs.org/blog/next-9-1#public-directory-support 152 | # public 153 | 154 | # vuepress build output 155 | .vuepress/dist 156 | 157 | # vuepress v2.x temp and cache directory 158 | .temp 159 | 160 | # Docusaurus cache and generated files 161 | .docusaurus 162 | 163 | # Serverless directories 164 | .serverless/ 165 | 166 | # FuseBox cache 167 | .fusebox/ 168 | 169 | # DynamoDB Local files 170 | .dynamodb/ 171 | 172 | # TernJS port file 173 | .tern-port 174 | 175 | # Stores VSCode versions used for testing VSCode extensions 176 | .vscode-test 177 | .vscode/ 178 | 179 | # yarn v2 180 | .yarn/cache 181 | .yarn/unplugged 182 | .yarn/build-state.yml 183 | .yarn/install-state.gz 184 | .pnp.* 185 | 186 | ### Node Patch ### 187 | # Serverless Webpack directories 188 | .webpack/ 189 | 190 | # Optional stylelint cache 191 | 192 | # SvelteKit build / generate output 193 | .svelte-kit 194 | 195 | ### Python ### 196 | # Byte-compiled / optimized / DLL files 197 | __pycache__/ 198 | *.py[cod] 199 | *$py.class 200 | 201 | # C extensions 202 | 203 | # Distribution / packaging 204 | .Python 205 | build/ 206 | develop-eggs/ 207 | dist/ 208 | downloads/ 209 | eggs/ 210 | .eggs/ 211 | lib/ 212 | lib64/ 213 | parts/ 214 | sdist/ 215 | var/ 216 | wheels/ 217 | share/python-wheels/ 218 | *.egg-info/ 219 | .installed.cfg 220 | *.egg 221 | MANIFEST 222 | 223 | # PyInstaller 224 | # Usually these files are written by a python script from a template 225 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 226 | *.manifest 227 | *.spec 228 | 229 | # Installer logs 230 | pip-log.txt 231 | pip-delete-this-directory.txt 232 | 233 | # Unit test / coverage reports 234 | htmlcov/ 235 | .tox/ 236 | .nox/ 237 | .coverage 238 | .coverage.* 239 | nosetests.xml 240 | coverage.xml 241 | *.cover 242 | *.py,cover 243 | .hypothesis/ 244 | .pytest_cache/ 245 | cover/ 246 | 247 | # Translations 248 | *.mo 249 | *.pot 250 | 251 | # Django stuff: 252 | local_settings.py 253 | db.sqlite3 254 | db.sqlite3-journal 255 | 256 | # Flask stuff: 257 | instance/ 258 | .webassets-cache 259 | 260 | # Scrapy stuff: 261 | .scrapy 262 | 263 | # Sphinx documentation 264 | docs/_build/ 265 | 266 | # PyBuilder 267 | .pybuilder/ 268 | target/ 269 | 270 | # Jupyter Notebook 271 | .ipynb_checkpoints 272 | 273 | # IPython 274 | profile_default/ 275 | ipython_config.py 276 | 277 | # pyenv 278 | # For a library or package, you might want to ignore these files since the code is 279 | # intended to run in multiple environments; otherwise, check them in: 280 | # .python-version 281 | 282 | # pipenv 283 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 284 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 285 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 286 | # install all needed dependencies. 287 | #Pipfile.lock 288 | 289 | # poetry 290 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 291 | # This is especially recommended for binary packages to ensure reproducibility, and is more 292 | # commonly ignored for libraries. 293 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 294 | #poetry.lock 295 | 296 | # pdm 297 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 298 | #pdm.lock 299 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 300 | # in version control. 301 | # https://pdm.fming.dev/#use-with-ide 302 | .pdm.toml 303 | 304 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 305 | __pypackages__/ 306 | 307 | # Celery stuff 308 | celerybeat-schedule 309 | celerybeat.pid 310 | 311 | # SageMath parsed files 312 | *.sage.py 313 | 314 | # Environments 315 | .venv 316 | env/ 317 | venv/ 318 | ENV/ 319 | env.bak/ 320 | venv.bak/ 321 | 322 | # Spyder project settings 323 | .spyderproject 324 | .spyproject 325 | 326 | # Rope project settings 327 | .ropeproject 328 | 329 | # mkdocs documentation 330 | /site 331 | 332 | # mypy 333 | .mypy_cache/ 334 | .dmypy.json 335 | dmypy.json 336 | 337 | # Pyre type checker 338 | .pyre/ 339 | 340 | # pytype static type analyzer 341 | .pytype/ 342 | 343 | # Cython debug symbols 344 | cython_debug/ 345 | 346 | # PyCharm 347 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 348 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 349 | # and can be added to the global gitignore or merged into this file. For a more nuclear 350 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 351 | #.idea/ 352 | 353 | ### Python Patch ### 354 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 355 | poetry.toml 356 | 357 | # ruff 358 | .ruff_cache/ 359 | 360 | # LSP config files 361 | pyrightconfig.json 362 | 363 | # End of https://www.toptal.com/developers/gitignore/api/node,java,python,go 364 | .idea 365 | *.key 366 | *.pem 367 | *.pub 368 | 369 | # IDE 370 | .codebuddy 371 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Overview 4 | 5 | Act in the best interests of the community, the government of British Columbia and your fellow collaborators. We welcome and appreciate your contributions, in any capacity. 6 | 7 | ## Our Pledge 8 | 9 | We as members, contributors, and leaders pledge to make participation in our 10 | community a harassment-free experience for everyone, regardless of age, body 11 | size, visible or invisible disability, ethnicity, sex characteristics, gender 12 | identity and expression, level of experience, education, socio-economic status, 13 | nationality, personal appearance, race, religion, or sexual identity 14 | and orientation. 15 | 16 | We pledge to act and interact in ways that contribute to an open, welcoming, 17 | diverse, inclusive, and healthy community. 18 | 19 | ## Our Standards 20 | 21 | Examples of behavior that contributes to a positive environment for our 22 | community include: 23 | 24 | * Demonstrating empathy and kindness toward other people 25 | * Being respectful of differing opinions, viewpoints, and experiences 26 | * Giving and gracefully accepting constructive feedback 27 | * Accepting responsibility and apologizing to those affected by our mistakes, 28 | and learning from the experience 29 | * Focusing on what is best not just for us as individuals, but for the 30 | overall community 31 | 32 | Examples of unacceptable behavior include: 33 | 34 | * The use of sexualized language or imagery, and sexual attention or 35 | advances of any kind 36 | * Trolling, insulting or derogatory comments, and personal or political attacks 37 | * Public or private harassment 38 | * Publishing others' private information, such as a physical or email 39 | address, without their explicit permission 40 | * Other conduct which could reasonably be considered inappropriate in a 41 | professional setting 42 | 43 | ## Enforcement Responsibilities 44 | 45 | Community leaders are responsible for clarifying and enforcing our standards of 46 | acceptable behavior and will take appropriate and fair corrective action in 47 | response to any behavior that they deem inappropriate, threatening, offensive, 48 | or harmful. 49 | 50 | Community leaders have the right and responsibility to remove, edit, or reject 51 | comments, commits, code, wiki edits, issues, and other contributions that are 52 | not aligned to this Code of Conduct, and will communicate reasons for moderation 53 | decisions when appropriate. 54 | 55 | ## Scope 56 | 57 | This Code of Conduct applies within all community spaces, and also applies when 58 | an individual is officially representing the community in public spaces. 59 | Examples of representing our community include using an official e-mail address, 60 | posting via an official social media account, or acting as an appointed 61 | representative at an online or offline event. 62 | 63 | ## Enforcement 64 | 65 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 66 | reported to the community leaders responsible for enforcement at 67 | derek.roberts@gmail.com. 68 | All complaints will be reviewed and investigated promptly and fairly. 69 | 70 | All community leaders are obligated to respect the privacy and security of the 71 | reporter of any incident. 72 | 73 | ## Enforcement Guidelines 74 | 75 | Community leaders will follow these Community Impact Guidelines in determining 76 | the consequences for any action they deem in violation of this Code of Conduct: 77 | 78 | ### 1. Correction 79 | 80 | **Community Impact**: Use of inappropriate language or other behavior deemed 81 | unprofessional or unwelcome in the community. 82 | 83 | **Consequence**: A private, written warning from community leaders, providing 84 | clarity around the nature of the violation and an explanation of why the 85 | behavior was inappropriate. A public apology may be requested. 86 | 87 | ### 2. Warning 88 | 89 | **Community Impact**: A violation through a single incident or series 90 | of actions. 91 | 92 | **Consequence**: A warning with consequences for continued behavior. No 93 | interaction with the people involved, including unsolicited interaction with 94 | those enforcing the Code of Conduct, for a specified period of time. This 95 | includes avoiding interactions in community spaces as well as external channels 96 | like social media. Violating these terms may lead to a temporary or 97 | permanent ban. 98 | 99 | ### 3. Temporary Ban 100 | 101 | **Community Impact**: A serious violation of community standards, including 102 | sustained inappropriate behavior. 103 | 104 | **Consequence**: A temporary ban from any sort of interaction or public 105 | communication with the community for a specified period of time. No public or 106 | private interaction with the people involved, including unsolicited interaction 107 | with those enforcing the Code of Conduct, is allowed during this period. 108 | Violating these terms may lead to a permanent ban. 109 | 110 | ### 4. Permanent Ban 111 | 112 | **Community Impact**: Demonstrating a pattern of violation of community 113 | standards, including sustained inappropriate behavior, harassment of an 114 | individual, or aggression toward or disparagement of classes of individuals. 115 | 116 | **Consequence**: A permanent ban from any sort of public interaction within 117 | the community. 118 | 119 | ## Attribution 120 | 121 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 122 | version 2.0, available at 123 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 124 | 125 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 126 | enforcement ladder](https://github.com/mozilla/diversity). 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | 130 | For answers to common questions about this code of conduct, see the FAQ at 131 | https://www.contributor-covenant.org/faq. Translations are available at 132 | https://www.contributor-covenant.org/translations. 133 | -------------------------------------------------------------------------------- /COMPLIANCE.yaml: -------------------------------------------------------------------------------- 1 | name: compliance 2 | description: | 3 | This document is used to track a projects PIA and STRA 4 | compliance. 5 | spec: 6 | - name: PIA 7 | status: not-required 8 | last-updated: '2022-01-26T23:07:19.992Z' 9 | - name: STRA 10 | status: not-required 11 | last-updated: '2022-01-26T23:07:19.992Z' 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Government employees, public and members of the private sector are encouraged to contribute to the repository by **creating a branch and submitting a pull request**. Outside forks come with permissions complications, but can still be accepted. 4 | 5 | (If you are new to GitHub, you might start with a [basic tutorial](https://help.github.com/articles/set-up-git) and check out a more detailed guide to [pull requests](https://help.github.com/articles/using-pull-requests/).) 6 | 7 | Pull requests will be evaluated by the repository guardians on a schedule and if deemed beneficial will be committed to the main branch. 8 | 9 | All contributors retain the original copyright to their stuff, but by contributing to this project, you grant a world-wide, royalty-free, perpetual, irrevocable, non-exclusive, transferable license to all users **under the terms of the [license](./LICENSE.md) under which this project is distributed**. 10 | -------------------------------------------------------------------------------- /HOWTO.md: -------------------------------------------------------------------------------- 1 | # This document contains various how-to guides. 2 | - [How To Pass Configuration as Env vars to the SPA frontend loaded in browser](#how-to-pass-configuration-as-env-vars-to-the-spa-frontend-loaded-in-browser) 3 | 4 | ### How To Pass Configuration as Env vars to the SPA frontend loaded in browser. 5 | 1. create a `env.ts` file in the source code, sample file [here](https://github.com/bcgov/nr-epd-organics-info/blob/main/frontend/src/env.ts) 6 | 2. Update vite.config.ts and add the following, [sample](https://github.com/bcgov/nr-epd-organics-info/blob/main/frontend/vite.config.ts#L18-L35) 7 | ```javascript 8 | { 9 | name: 'build-html', 10 | apply: 'build', 11 | transformIndexHtml: (html) => { 12 | return { 13 | html, 14 | tags: [ 15 | { 16 | tag: 'script', 17 | attrs: { 18 | src: '/env.js', 19 | }, 20 | injectTo: 'head', 21 | }, 22 | ], 23 | } 24 | }, 25 | }, 26 | ``` 27 | 3. Add similar section to Caddyfile which will return all your config data, [sample](https://github.com/bcgov/nr-epd-organics-info/blob/main/frontend/Caddyfile#L17C4-L22C6) 28 | ```javascript 29 | handle /env.js { 30 | header { 31 | Content-Type text/javascript 32 | } 33 | respond `window.config={"VITE_ENV_VAR":"{$VITE_ENV_VAR}"};` 34 | } 35 | ``` 36 | 4. Add the env vars to frontend deployment, [sample](https://github.com/bcgov/nr-epd-organics-info/blob/main/charts/nr-epd-organics-info/templates/frontend/templates/deployment.yaml#L38-L56) 37 | ```yaml 38 | - name: VITE_ENV_VAR 39 | value: {{ .Values.frontend.someEnvVar | quote }} 40 | ``` 41 | 5. feed these values during helm deploy either through set string or through values file. 42 | 6. Access the env.ts file as import in other files, [sample](https://github.com/bcgov/nr-epd-organics-info/blob/main/frontend/src/pages/map/layers/ZoomToResultsControl.tsx) 43 | ```javascript 44 | import { env } from '@/env' 45 | const zoomToResultsFeatureFlag = 46 | env.VITE_ZOOM_TO_RESULTS_CONTROL_FLAG === 'true' 47 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Province of British Columbia 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | This product currently has no support and is experimental. That could change in future. 6 | 7 | 8 | ## Reporting a Vulnerability 9 | 10 | Please report any issues or vulerabilities with an [issue](https://github.com/bcgov/quickstart-openshift/issues). 11 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Standard exclusions 2 | *.md 3 | .git 4 | .github 5 | .idea 6 | .vscode 7 | Dockerfile 8 | CODE_OF_CONDUCT* 9 | CONTRIBUTING* 10 | LICENSE* 11 | SECURITY* 12 | 13 | # Node exclusions 14 | dist 15 | node_modules 16 | 17 | # App-specific exclusions 18 | coverage 19 | cypress 20 | e2e 21 | migrations 22 | output 23 | test 24 | tests 25 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM node:22.16.0-slim AS build 3 | 4 | # Copy, build static files; see .dockerignore for exclusions 5 | WORKDIR /app 6 | COPY . ./ 7 | ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x 8 | RUN npm run deploy 9 | 10 | # Dependencies 11 | FROM node:22.16.0-slim AS dependencies 12 | 13 | # Copy, build static files; see .dockerignore for exclusions 14 | WORKDIR /app 15 | COPY . ./ 16 | ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x 17 | RUN npm ci --ignore-scripts --no-update-notifier --omit=dev 18 | 19 | # Deploy using minimal Distroless image 20 | FROM gcr.io/distroless/nodejs22-debian12:nonroot 21 | ENV NODE_ENV=production 22 | 23 | # Copy app and dependencies 24 | WORKDIR /app 25 | COPY --from=dependencies /app/node_modules ./node_modules 26 | COPY --from=build /app/node_modules/@prisma ./node_modules/@prisma 27 | COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma 28 | COPY --from=build /app/node_modules/prisma ./node_modules/prisma 29 | COPY --from=build /app/dist ./dist 30 | 31 | # Boilerplate, not used in OpenShift/Kubernetes 32 | EXPOSE 3000 33 | HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/api 34 | 35 | # Nonroot user, limit heap size to 50 MB 36 | USER nonroot 37 | CMD ["--max-old-space-size=50", "/app/dist/main"] 38 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "builder": "swc", 6 | "typeCheck": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "NRIDS", 3 | "license": "Apache-2.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "prisma-generate": "prisma generate", 7 | "prebuild": "rimraf dist", 8 | "build": "prisma generate && nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "deploy": "npm ci --ignore-scripts --no-update-notifier && npm run build", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "lint:staged": "./node_modules/.bin/lint-staged", 17 | "make-badges": "istanbul-badges-readme --logo=vitest --exitCode=1", 18 | "make-badges:ci": "npm run make-badges -- --ci", 19 | "test": "vitest --dir src", 20 | "test:cov": "prisma generate && vitest run --coverage", 21 | "test:e2e": "vitest --dir test" 22 | }, 23 | "dependencies": { 24 | "@nestjs/cli": "^11.0.2", 25 | "@nestjs/common": "^11.0.6", 26 | "@nestjs/config": "^4.0.0", 27 | "@nestjs/core": "^11.0.6", 28 | "@nestjs/platform-express": "^11.0.6", 29 | "@nestjs/schematics": "^11.0.0", 30 | "@nestjs/swagger": "^11.0.3", 31 | "@nestjs/terminus": "^11.0.0", 32 | "@nestjs/testing": "^11.0.6", 33 | "@prisma/client": "^6.3.0", 34 | "prisma": "^6.2.1", 35 | "dotenv": "^16.4.7", 36 | "express-prom-bundle": "^8.0.0", 37 | "helmet": "^8.0.0", 38 | "nest-winston": "^1.10.1", 39 | "pg": "^8.13.1", 40 | "prom-client": "^15.1.3", 41 | "reflect-metadata": "^0.2.2", 42 | "rimraf": "^6.0.1", 43 | "swagger-ui-express": "^5.0.1", 44 | "winston": "^3.17.0" 45 | }, 46 | "devDependencies": { 47 | "@swc/cli": "^0.6.0", 48 | "@swc/core": "^1.10.12", 49 | "@types/express": "^5.0.0", 50 | "@types/node": "^22.12.0", 51 | "@types/supertest": "^6.0.2", 52 | "@typescript-eslint/eslint-plugin": "^7.0.0", 53 | "@typescript-eslint/parser": "^7.0.0", 54 | "@vitest/coverage-v8": "^3.0.2", 55 | "eslint": "^8.57.0", 56 | "eslint-config-airbnb-base": "^15.0.0", 57 | "eslint-config-prettier": "^9.0.0", 58 | "eslint-config-standard": "^17.0.0", 59 | "eslint-plugin-import": "^2.27.5", 60 | "eslint-plugin-n": "^16.6.2", 61 | "eslint-plugin-prettier": "^5.0.0", 62 | "eslint-plugin-promise": "^6.1.1", 63 | "istanbul-badges-readme": "^1.9.0", 64 | "lint-staged": "^16.0.0", 65 | "prettier": "^3.4.2", 66 | 67 | "source-map-support": "^0.5.21", 68 | "supertest": "^7.0.0", 69 | "ts-loader": "^9.5.2", 70 | "ts-node": "^10.9.2", 71 | "tsconfig-paths": "^4.2.0", 72 | "typescript": "^5.2.2", 73 | "unplugin-swc": "^1.5.1", 74 | "vitest": "^3.0.2" 75 | }, 76 | "lint-staged": { 77 | "*.{js,ts}": "./node_modules/.bin/eslint --cache --fix" 78 | }, 79 | "overrides": { 80 | "minimist@<1.2.6": "1.2.6", 81 | "reflect-metadata": "^0.2.2" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["metrics"] 4 | binaryTargets = ["native", "debian-openssl-3.0.x"] 5 | } 6 | 7 | datasource db { 8 | provider = "postgresql" 9 | url = env("DATABASE_URL") 10 | } 11 | 12 | model users { 13 | id Decimal @id(map: "USER_PK") @default(dbgenerated("nextval('\"USER_SEQ\"'::regclass)")) @db.Decimal 14 | name String @db.VarChar(200) 15 | email String @db.VarChar(200) 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello Backend!"', () => { 19 | expect(appController.getHello()).toBe('Hello Backend!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { MiddlewareConsumer, Module, RequestMethod } from "@nestjs/common"; 3 | import { HTTPLoggerMiddleware } from "./middleware/req.res.logger"; 4 | import { PrismaService } from "src/prisma.service"; 5 | import { ConfigModule } from "@nestjs/config"; 6 | import { UsersModule } from "./users/users.module"; 7 | import { AppService } from "./app.service"; 8 | import { AppController } from "./app.controller"; 9 | import { MetricsController } from "./metrics.controller"; 10 | import { TerminusModule } from '@nestjs/terminus'; 11 | import { HealthController } from "./health.controller"; 12 | 13 | 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule.forRoot(), 18 | TerminusModule, 19 | UsersModule 20 | ], 21 | controllers: [AppController,MetricsController, HealthController], 22 | providers: [AppService, PrismaService] 23 | }) 24 | export class AppModule { // let's add a middleware on all routes 25 | configure(consumer: MiddlewareConsumer) { 26 | consumer.apply(HTTPLoggerMiddleware).exclude({ path: 'metrics', method: RequestMethod.ALL }, { path: 'health', method: RequestMethod.ALL }).forRoutes('*'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return "Hello Backend!"; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { NestExpressApplication } from "@nestjs/platform-express"; 2 | import { bootstrap } from "./app"; 3 | 4 | vi.mock("prom-client", () => ({ 5 | Registry: vi.fn().mockImplementation(() => ({})), 6 | collectDefaultMetrics: vi.fn().mockImplementation(() => ({})), 7 | })); 8 | vi.mock("express-prom-bundle", () => ({ 9 | default: vi.fn().mockImplementation(() => ({})), 10 | })); 11 | vi.mock("src/middleware/prom", () => ({ 12 | metricsMiddleware: vi.fn().mockImplementation((_req, _res, next) => next()), 13 | })); 14 | 15 | describe("main", () => { 16 | let app: NestExpressApplication; 17 | 18 | beforeAll(async () => { 19 | app = await bootstrap(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await app.close(); 24 | }); 25 | 26 | it("should start the application", async () => { 27 | expect(app).toBeDefined(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | import { AppModule } from './app.module'; 4 | import { customLogger } from './common/logger.config'; 5 | import { NestExpressApplication } from '@nestjs/platform-express'; 6 | import helmet from 'helmet'; 7 | import { VersioningType } from '@nestjs/common'; 8 | import { metricsMiddleware } from "src/middleware/prom"; 9 | 10 | /** 11 | * 12 | */ 13 | export async function bootstrap() { 14 | const app: NestExpressApplication = 15 | await NestFactory.create(AppModule, { 16 | logger: customLogger, 17 | }); 18 | app.use(helmet()); 19 | app.enableCors(); 20 | app.set("trust proxy", 1); 21 | app.use(metricsMiddleware); 22 | app.enableShutdownHooks(); 23 | app.setGlobalPrefix("api"); 24 | app.enableVersioning({ 25 | type: VersioningType.URI, 26 | prefix: "v", 27 | }); 28 | const config = new DocumentBuilder() 29 | .setTitle("Users example") 30 | .setDescription("The user API description") 31 | .setVersion("1.0") 32 | .addTag("users") 33 | .build(); 34 | 35 | const document = SwaggerModule.createDocument(app, config); 36 | SwaggerModule.setup("/api/docs", app, document); 37 | return app; 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/common/logger.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { customLogger } from "./logger.config"; 2 | 3 | describe("CustomLogger", () => { 4 | it("should be defined", () => { 5 | expect(customLogger).toBeDefined(); 6 | }); 7 | 8 | it("should log a message", () => { 9 | const spy = vi.spyOn(customLogger, "verbose"); 10 | customLogger.verbose("Test message"); 11 | expect(spy).toHaveBeenCalledWith("Test message"); 12 | spy.mockRestore(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /backend/src/common/logger.config.ts: -------------------------------------------------------------------------------- 1 | import {WinstonModule, utilities} from 'nest-winston'; 2 | import * as winston from 'winston'; 3 | import {LoggerService} from "@nestjs/common"; 4 | 5 | const globalLoggerFormat: winston.Logform.Format = winston.format.timestamp({format: "YYYY-MM-DD hh:mm:ss.SSS"}); 6 | 7 | const localLoggerFormat: winston.Logform.Format = winston.format.combine( 8 | winston.format.colorize(), 9 | winston.format.align(), 10 | utilities.format.nestLike('Backend', {prettyPrint: true}) 11 | ); 12 | 13 | 14 | export const customLogger: LoggerService = WinstonModule.createLogger({ 15 | transports: [ 16 | new winston.transports.Console({ 17 | level: 'silly', 18 | format: winston.format.combine( 19 | globalLoggerFormat, 20 | localLoggerFormat, 21 | winston.format.colorize({ level: true }) 22 | ), 23 | }), 24 | ], 25 | exitOnError: false, 26 | }); 27 | -------------------------------------------------------------------------------- /backend/src/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common"; 2 | import { HealthCheckService, HealthCheck, PrismaHealthIndicator } from "@nestjs/terminus"; 3 | import { PrismaService } from "src/prisma.service"; 4 | @Controller("health") 5 | export class HealthController { 6 | constructor( 7 | private health: HealthCheckService, 8 | private prisma: PrismaHealthIndicator, 9 | private readonly prismaService: PrismaService, 10 | ) {} 11 | 12 | @Get() 13 | @HealthCheck() 14 | check() { 15 | return this.health.check([ 16 | () => this.prisma.pingCheck('prisma', this.prismaService), 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import {NestExpressApplication} from "@nestjs/platform-express"; 2 | import {bootstrap} from "./app"; 3 | import {Logger} from "@nestjs/common"; 4 | const logger = new Logger('NestApplication'); 5 | bootstrap().then(async (app: NestExpressApplication) => { 6 | await app.listen(3000); 7 | logger.log(`Listening on ${await app.getUrl()}`); 8 | logger.log(`Process start up took ${process.uptime()} seconds`); 9 | }).catch(err=>{ 10 | logger.error(err); 11 | }); 12 | -------------------------------------------------------------------------------- /backend/src/metrics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Res } from "@nestjs/common"; 2 | import { Response } from "express"; 3 | import { register } from "src/middleware/prom"; 4 | import { PrismaService } from "src/prisma.service"; 5 | @Controller("metrics") 6 | export class MetricsController { 7 | constructor(private prisma: PrismaService) {} 8 | 9 | @Get() 10 | async getMetrics(@Res() res: Response) { 11 | const prismaMetrics = await this.prisma.$metrics.prometheus(); 12 | const appMetrics = await register.metrics(); 13 | res.end(prismaMetrics + appMetrics); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/middleware/prom.ts: -------------------------------------------------------------------------------- 1 | import * as prom from 'prom-client'; 2 | import promBundle from 'express-prom-bundle'; 3 | const register = new prom.Registry(); 4 | prom.collectDefaultMetrics({ register }); 5 | const metricsMiddleware = promBundle({ 6 | includeMethod: true, 7 | includePath: true, 8 | metricsPath: '/prom-metrics', 9 | promRegistry: register, 10 | }); 11 | export { metricsMiddleware, register }; 12 | -------------------------------------------------------------------------------- /backend/src/middleware/req.res.logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from "@nestjs/testing"; 2 | import { HTTPLoggerMiddleware } from "./req.res.logger"; 3 | import { Request, Response } from "express"; 4 | import { Logger } from "@nestjs/common"; 5 | 6 | describe("HTTPLoggerMiddleware", () => { 7 | let middleware: HTTPLoggerMiddleware; 8 | let logger: Logger; 9 | 10 | beforeEach(async () => { 11 | const module = await Test.createTestingModule({ 12 | providers: [HTTPLoggerMiddleware, Logger], 13 | }).compile(); 14 | 15 | middleware = module.get(HTTPLoggerMiddleware); 16 | logger = module.get(Logger); 17 | }); 18 | it("should log the correct information", () => { 19 | const request: Request = { 20 | method: "GET", 21 | originalUrl: "/test", 22 | get: () => "Test User Agent", 23 | } as unknown as Request; 24 | 25 | const response: Response = { 26 | statusCode: 200, 27 | get: () => "100", 28 | on: (event: string, cb: () => void) => { 29 | if (event === "finish") { 30 | cb(); 31 | } 32 | }, 33 | } as unknown as Response; 34 | 35 | const loggerSpy = vi.spyOn(middleware["logger"], "log"); 36 | 37 | middleware.use(request, response, () => {}); 38 | 39 | expect(loggerSpy).toHaveBeenCalledWith( 40 | `GET /test 200 100 - Test User Agent`, 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /backend/src/middleware/req.res.logger.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; 3 | 4 | @Injectable() 5 | export class HTTPLoggerMiddleware implements NestMiddleware { 6 | private logger = new Logger("HTTP"); 7 | 8 | use(request: Request, response: Response, next: NextFunction): void { 9 | const { method, originalUrl } = request; 10 | 11 | response.on("finish", () => { 12 | const { statusCode } = response; 13 | const contentLength = response.get("content-length") || "-"; 14 | const hostedHttpLogFormat = `${method} ${originalUrl} ${statusCode} ${contentLength} - ${request.get( 15 | "user-agent", 16 | )}`; 17 | this.logger.log(hostedHttpLogFormat); 18 | }); 19 | next(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { PrismaService } from "src/prisma.service"; 3 | 4 | @Module({ 5 | providers: [PrismaService], 6 | exports: [PrismaService], 7 | }) 8 | export class PrismaModule {} -------------------------------------------------------------------------------- /backend/src/prisma.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { PrismaService } from "./prisma.service"; 3 | import { Logger } from "@nestjs/common"; 4 | 5 | describe("PrismaService", () => { 6 | let service: PrismaService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [PrismaService], 11 | }).compile(); 12 | 13 | service = module.get(PrismaService); 14 | }); 15 | 16 | afterEach(async () => { 17 | await service.$disconnect(); 18 | }); 19 | 20 | it("should be defined", () => { 21 | expect(service).toBeDefined(); 22 | }); 23 | 24 | }); 25 | -------------------------------------------------------------------------------- /backend/src/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from "@nestjs/common"; 2 | import { PrismaClient, Prisma } from "@prisma/client"; 3 | 4 | const DB_HOST = process.env.POSTGRES_HOST || "localhost"; 5 | const DB_USER = process.env.POSTGRES_USER || "postgres"; 6 | const DB_PWD = encodeURIComponent(process.env.POSTGRES_PASSWORD || "default"); // this needs to be encoded, if the password contains special characters it will break connection string. 7 | const DB_PORT = process.env.POSTGRES_PORT || 5432; 8 | const DB_NAME = process.env.POSTGRES_DATABASE || "postgres"; 9 | const DB_SCHEMA = process.env.POSTGRES_SCHEMA || "users"; 10 | const dataSourceURL = `postgresql://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&connection_limit=5`; 11 | 12 | @Injectable() 13 | class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { 14 | private logger = new Logger("PRISMA"); 15 | private static instance: PrismaService; 16 | constructor() { 17 | if (PrismaService.instance) { 18 | return PrismaService.instance; 19 | } 20 | super({ 21 | errorFormat: 'pretty', 22 | datasources: { 23 | db: { 24 | url: dataSourceURL, 25 | }, 26 | }, 27 | log: [ 28 | { emit: 'event', level: 'query' }, 29 | { emit: 'stdout', level: 'info' }, 30 | { emit: 'stdout', level: 'warn' }, 31 | { emit: 'stdout', level: 'error' }, 32 | ] 33 | }); 34 | PrismaService.instance = this; 35 | } 36 | 37 | 38 | async onModuleInit() { 39 | await this.$connect(); 40 | this.$on('query', (e: Prisma.QueryEvent) => { 41 | // dont print the health check queries 42 | if(e?.query?.includes("SELECT 1")) return; 43 | this.logger.log( 44 | `Query: ${e.query} - Params: ${e.params} - Duration: ${e.duration}ms`, 45 | ); 46 | }); 47 | } 48 | 49 | async onModuleDestroy() { 50 | await this.$disconnect(); 51 | } 52 | } 53 | 54 | export { PrismaService }; 55 | -------------------------------------------------------------------------------- /backend/src/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | import { UserDto } from './user.dto'; 3 | 4 | export class CreateUserDto extends PickType(UserDto, [ 5 | 'email', 6 | 'name', 7 | ] as const) {} 8 | -------------------------------------------------------------------------------- /backend/src/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserDto } from './create-user.dto'; 2 | 3 | export class UpdateUserDto extends CreateUserDto {} 4 | -------------------------------------------------------------------------------- /backend/src/users/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UserDto { 4 | @ApiProperty({ 5 | description: 'The ID of the user', 6 | // default: '9999', 7 | }) 8 | id: number; 9 | 10 | @ApiProperty({ 11 | description: 'The name of the user', 12 | // default: 'username', 13 | }) 14 | name: string; 15 | 16 | @ApiProperty({ 17 | description: 'The contact email of the user', 18 | default: '', 19 | }) 20 | email: string; 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { UsersController } from "./users.controller"; 3 | import { UsersService } from "./users.service"; 4 | import request from "supertest"; 5 | import { HttpException, INestApplication } from "@nestjs/common"; 6 | import { CreateUserDto } from "./dto/create-user.dto"; 7 | import { UpdateUserDto } from "./dto/update-user.dto"; 8 | import { UserDto } from "./dto/user.dto"; 9 | import { PrismaService } from "src/prisma.service"; 10 | describe("UserController", () => { 11 | let controller: UsersController; 12 | let usersService: UsersService; 13 | let app: INestApplication; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | controllers: [UsersController], 18 | providers: [ 19 | UsersService, 20 | { 21 | provide: PrismaService, 22 | useValue: {}, 23 | }, 24 | ], 25 | }).compile(); 26 | usersService = module.get(UsersService); 27 | controller = module.get(UsersController); 28 | app = module.createNestApplication(); 29 | await app.init(); 30 | }); 31 | // Close the app after each test 32 | afterEach(async () => { 33 | await app.close(); 34 | }); 35 | 36 | it("should be defined", () => { 37 | expect(controller).toBeDefined(); 38 | }); 39 | 40 | describe("create", () => { 41 | it("should call the service create method with the given dto and return the result", async () => { 42 | // Arrange 43 | const createUserDto: CreateUserDto = { 44 | email: "test@example.com", 45 | name: "Test User", 46 | }; 47 | const expectedResult = { 48 | id: 1, 49 | ...createUserDto, 50 | }; 51 | vi.spyOn(usersService, "create").mockResolvedValue(expectedResult); 52 | 53 | // Act 54 | const result = await controller.create(createUserDto); 55 | 56 | // Assert 57 | expect(usersService.create).toHaveBeenCalledWith(createUserDto); 58 | expect(result).toEqual(expectedResult); 59 | }); 60 | }); 61 | describe("findAll", () => { 62 | it("should return an array of users", async () => { 63 | const result = []; 64 | result.push({ id: 1, name: "Alice", email: "test@gmail.com" }); 65 | vi.spyOn(usersService, "findAll").mockResolvedValue(result); 66 | expect(await controller.findAll()).toBe(result); 67 | }); 68 | }); 69 | describe("findOne", () => { 70 | it("should return a user object", async () => { 71 | const result: UserDto = { 72 | id: 1, 73 | name: "john", 74 | email: "John_Doe@gmail.com", 75 | }; 76 | vi.spyOn(usersService, "findOne").mockResolvedValue(result); 77 | expect(await controller.findOne("1")).toBe(result); 78 | }); 79 | it("should throw error if user not found", async () => { 80 | vi.spyOn(usersService, "findOne").mockResolvedValue(undefined); 81 | try { 82 | await controller.findOne("1"); 83 | } catch (e) { 84 | expect(e).toBeInstanceOf(HttpException); 85 | expect(e.message).toBe("User not found."); 86 | } 87 | }); 88 | }); 89 | describe("update", () => { 90 | it("should update and return a user object", async () => { 91 | const id = "1"; 92 | const updateUserDto: UpdateUserDto = { 93 | email: "johndoe@example.com", 94 | name: "John Doe", 95 | }; 96 | const userDto: UserDto = { 97 | id: 1, 98 | name: "John Doe", 99 | email: "johndoe@example.com", 100 | }; 101 | vi.spyOn(usersService, "update").mockResolvedValue(userDto); 102 | 103 | expect(await controller.update(id, updateUserDto)).toBe(userDto); 104 | expect(usersService.update).toHaveBeenCalledWith(+id, updateUserDto); 105 | }); 106 | }); 107 | describe("remove", () => { 108 | it("should remove a user", async () => { 109 | const id = "1"; 110 | vi.spyOn(usersService, "remove").mockResolvedValue(undefined); 111 | 112 | expect(await controller.remove(id)).toBeUndefined(); 113 | expect(usersService.remove).toHaveBeenCalledWith(+id); 114 | }); 115 | }); 116 | // Test the GET /users/search endpoint 117 | describe("GET /users/search", () => { 118 | // Test with valid query parameters 119 | it("given valid query parameters_should return an array of users with pagination metadata", async () => { 120 | // Mock the usersService.searchUsers method to return a sample result 121 | const result = { 122 | users: [ 123 | { id: 1, name: "Alice", email: "alice@example.com" }, 124 | { id: 2, name: "Adam", email: "Adam@example.com" }, 125 | ], 126 | page: 1, 127 | limit: 10, 128 | sort: '{"name":"ASC"}', 129 | filter: '[{"key":"name","operation":"like","value":"A"}]', 130 | total: 2, 131 | totalPages: 1, 132 | }; 133 | vi.spyOn(usersService, "searchUsers").mockImplementation( 134 | async () => result, 135 | ); 136 | 137 | // Make a GET request with query parameters and expect a 200 status code and the result object 138 | return request(app.getHttpServer()) 139 | .get("/users/search") 140 | .query({ 141 | page: 1, 142 | limit: 10, 143 | sort: '{"name":"ASC"}', 144 | filter: '[{"key":"name","operation":"like","value":"A"}]', 145 | }) 146 | .expect(200) 147 | .expect(result); 148 | }); 149 | 150 | // Test with invalid query parameters 151 | it("given invalid query parameters_should return a 400 status code with an error message", async () => { 152 | // Make a GET request with invalid query parameters and expect a 400 status code and an error message 153 | return request(app.getHttpServer()) 154 | .get("/users/search") 155 | .query({ 156 | page: "invalid", 157 | limit: "invalid", 158 | }) 159 | .expect(400) 160 | .expect({ 161 | statusCode: 400, 162 | message: "Invalid query parameters", 163 | }); 164 | }); 165 | it("given sort and filter as invalid query parameters_should return a 400 status code with an error message", async () => { 166 | // Make a GET request with invalid query parameters and expect a 400 status code and an error message 167 | vi.spyOn(usersService, "searchUsers").mockImplementation(async () => { 168 | throw new HttpException("Invalid query parameters", 400); 169 | }); 170 | return request(app.getHttpServer()) 171 | .get("/users/search") 172 | .query({ 173 | page: 1, 174 | limit: 10, 175 | sort: "invalid", 176 | filter: "invalid", 177 | }) 178 | .expect(400) 179 | .expect({ 180 | statusCode: 400, 181 | message: "Invalid query parameters", 182 | }); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /backend/src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Put, 7 | Param, 8 | Delete, Query, HttpException, 9 | } from "@nestjs/common"; 10 | import {ApiTags} from "@nestjs/swagger"; 11 | import {UsersService} from "./users.service"; 12 | import {CreateUserDto} from "./dto/create-user.dto"; 13 | import {UpdateUserDto} from "./dto/update-user.dto"; 14 | import { UserDto } from "./dto/user.dto"; 15 | 16 | @ApiTags("users") 17 | @Controller({path: "users", version: "1"}) 18 | export class UsersController { 19 | constructor(private readonly usersService: UsersService) { 20 | } 21 | 22 | @Post() 23 | create(@Body() createUserDto: CreateUserDto) { 24 | return this.usersService.create(createUserDto); 25 | } 26 | 27 | @Get() 28 | findAll() : Promise { 29 | return this.usersService.findAll(); 30 | } 31 | 32 | @Get("search") // it must be ahead of the below Get(":id") to avoid conflict 33 | async searchUsers( 34 | @Query("page") page: number, 35 | @Query("limit") limit: number, 36 | @Query("sort") sort: string, // JSON string to store sort key and sort value, ex: {name: "ASC"} 37 | @Query("filter") filter: string // JSON array for key, operation and value, ex: [{key: "name", operation: "like", value: "Peter"}] 38 | ) { 39 | if (isNaN(page) || isNaN(limit)) { 40 | throw new HttpException("Invalid query parameters", 400); 41 | } 42 | return this.usersService.searchUsers(page, limit, sort, filter); 43 | } 44 | 45 | @Get(":id") 46 | async findOne(@Param("id") id: string) { 47 | const user = await this.usersService.findOne(+id); 48 | if (!user) { 49 | throw new HttpException("User not found.", 404); 50 | } 51 | return user; 52 | } 53 | 54 | @Put(":id") 55 | update(@Param("id") id: string, @Body() updateUserDto: UpdateUserDto) { 56 | return this.usersService.update(+id, updateUserDto); 57 | } 58 | 59 | @Delete(":id") 60 | remove(@Param("id") id: string) { 61 | return this.usersService.remove(+id); 62 | } 63 | 64 | 65 | } 66 | -------------------------------------------------------------------------------- /backend/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { UsersService } from "./users.service"; 3 | import { UsersController } from "./users.controller"; 4 | import { PrismaModule } from "src/prisma.module"; 5 | 6 | @Module({ 7 | controllers: [UsersController], 8 | providers: [UsersService], 9 | imports: [PrismaModule] 10 | }) 11 | export class UsersModule { 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from "@nestjs/testing"; 2 | import { Test } from "@nestjs/testing"; 3 | import { UsersService } from "./users.service"; 4 | import { PrismaService } from "src/prisma.service"; 5 | import { Prisma } from "@prisma/client"; 6 | 7 | describe("UserService", () => { 8 | let service: UsersService; 9 | let prisma: PrismaService; 10 | 11 | const savedUser1 = { 12 | id: new Prisma.Decimal(1), 13 | name: "Test Numone", 14 | email: "numone@test.com", 15 | }; 16 | const savedUser2 = { 17 | id: new Prisma.Decimal(2), 18 | name: "Test Numtwo", 19 | email: "numtwo@test.com", 20 | }; 21 | const oneUser = { 22 | id: 1, 23 | name: "Test Numone", 24 | email: "numone@test.com", 25 | }; 26 | const updateUser = { 27 | id: 1, 28 | name: "Test Numone update", 29 | email: "numoneupdate@test.com", 30 | }; 31 | const updatedUser = { 32 | id: new Prisma.Decimal(1), 33 | name: "Test Numone update", 34 | email: "numoneupdate@test.com", 35 | }; 36 | 37 | const twoUser = { 38 | id: 2, 39 | name: "Test Numtwo", 40 | email: "numtwo@test.com", 41 | }; 42 | 43 | const userArray = [oneUser, twoUser]; 44 | const savedUserArray = [savedUser1, savedUser2]; 45 | 46 | beforeEach(async () => { 47 | const module: TestingModule = await Test.createTestingModule({ 48 | providers: [ 49 | UsersService, 50 | { 51 | provide: PrismaService, 52 | useValue: { 53 | users: { 54 | findMany: vi.fn().mockResolvedValue(savedUserArray), 55 | findUnique: vi.fn().mockResolvedValue(savedUser1), 56 | create: vi.fn().mockResolvedValue(savedUser1), 57 | update: vi.fn().mockResolvedValue(updatedUser), 58 | delete: vi.fn().mockResolvedValue(true), 59 | count: vi.fn(), 60 | }, 61 | }, 62 | }, 63 | ], 64 | }).compile(); 65 | 66 | service = module.get(UsersService); 67 | prisma = module.get(PrismaService); 68 | }); 69 | 70 | it("should be defined", () => { 71 | expect(service).toBeDefined(); 72 | }); 73 | 74 | describe("createOne", () => { 75 | it("should successfully add a user", async () => { 76 | await expect(service.create(oneUser)).resolves.toEqual(oneUser); 77 | expect(prisma.users.create).toBeCalledTimes(1); 78 | }); 79 | }); 80 | 81 | describe("findAll", () => { 82 | it("should return an array of users", async () => { 83 | const users = await service.findAll(); 84 | expect(users).toEqual(userArray); 85 | }); 86 | }); 87 | 88 | describe("findOne", () => { 89 | it("should get a single user", async () => { 90 | await expect(service.findOne(1)).resolves.toEqual(oneUser); 91 | }); 92 | }); 93 | 94 | describe("update", () => { 95 | it("should call the update method", async () => { 96 | const user = await service.update(1, updateUser); 97 | expect(user).toEqual(updateUser); 98 | expect(prisma.users.update).toBeCalledTimes(1); 99 | }); 100 | }); 101 | 102 | describe("remove", () => { 103 | it("should return {deleted: true}", async () => { 104 | await expect(service.remove(2)).resolves.toEqual({ deleted: true }); 105 | }); 106 | it("should return {deleted: false, message: err.message}", async () => { 107 | const repoSpy = vi 108 | .spyOn(prisma.users, "delete") 109 | .mockRejectedValueOnce(new Error("Bad Delete Method.")); 110 | await expect(service.remove(-1)).resolves.toEqual({ 111 | deleted: false, 112 | message: "Bad Delete Method.", 113 | }); 114 | expect(repoSpy).toBeCalledTimes(1); 115 | }); 116 | }); 117 | 118 | describe("searchUsers", () => { 119 | it("should return a list of users with pagination and filtering", async () => { 120 | const page = 1; 121 | const limit = 10; 122 | const sortObject: Prisma.SortOrder = "asc"; 123 | const sort: any = `[{ "name": "${sortObject}" }]`; 124 | const filter: any = '[{ "name": { "equals": "Peter" } }]'; 125 | 126 | vi.spyOn(prisma.users, "findMany").mockResolvedValue([]); 127 | vi.spyOn(prisma.users, "count").mockResolvedValue(0); 128 | const result = await service.searchUsers(page, limit, sort, filter); 129 | 130 | expect(result).toEqual({ 131 | users: [], 132 | page, 133 | limit, 134 | total: 0, 135 | totalPages: 0, 136 | }); 137 | }); 138 | 139 | it("given no page should return a list of users with pagination and filtering with default page 1", async () => { 140 | const limit = 10; 141 | const sortObject: Prisma.SortOrder = "asc"; 142 | const sort: any = `[{ "name": "${sortObject}" }]`; 143 | const filter: any = '[{ "name": { "equals": "Peter" } }]'; 144 | 145 | vi.spyOn(prisma.users, "findMany").mockResolvedValue([]); 146 | vi.spyOn(prisma.users, "count").mockResolvedValue(0); 147 | const result = await service.searchUsers(null, limit, sort, filter); 148 | 149 | expect(result).toEqual({ 150 | users: [], 151 | page: 1, 152 | limit, 153 | total: 0, 154 | totalPages: 0, 155 | }); 156 | }); 157 | it("given no limit should return a list of users with pagination and filtering with default limit 10", async () => { 158 | const page = 1; 159 | const sortObject: Prisma.SortOrder = "asc"; 160 | const sort: any = `[{ "name": "${sortObject}" }]`; 161 | const filter: any = '[{ "name": { "equals": "Peter" } }]'; 162 | 163 | vi.spyOn(prisma.users, "findMany").mockResolvedValue([]); 164 | vi.spyOn(prisma.users, "count").mockResolvedValue(0); 165 | const result = await service.searchUsers(page, null, sort, filter); 166 | 167 | expect(result).toEqual({ 168 | users: [], 169 | page: 1, 170 | limit: 10, 171 | total: 0, 172 | totalPages: 0, 173 | }); 174 | }); 175 | 176 | it("given limit greater than 200 should return a list of users with pagination and filtering with default limit 10", async () => { 177 | const page = 1; 178 | const limit = 201; 179 | const sortObject: Prisma.SortOrder = "asc"; 180 | const sort: any = `[{ "name": "${sortObject}" }]`; 181 | const filter: any = '[{ "name": { "equals": "Peter" } }]'; 182 | 183 | vi.spyOn(prisma.users, "findMany").mockResolvedValue([]); 184 | vi.spyOn(prisma.users, "count").mockResolvedValue(0); 185 | const result = await service.searchUsers(page, limit, sort, filter); 186 | 187 | expect(result).toEqual({ 188 | users: [], 189 | page: 1, 190 | limit: 10, 191 | total: 0, 192 | totalPages: 0, 193 | }); 194 | }); 195 | it("given invalid JSON should throw error", async () => { 196 | const page = 1; 197 | const limit = 201; 198 | const sortObject: Prisma.SortOrder = "asc"; 199 | const sort: any = `[{ "name" "${sortObject}" }]`; 200 | const filter: any = '[{ "name": { "equals": "Peter" } }]'; 201 | try { 202 | await service.searchUsers(page, limit, sort, filter); 203 | } catch (e) { 204 | expect(e).toEqual(new Error("Invalid query parameters")); 205 | } 206 | }); 207 | }); 208 | describe("convertFiltersToPrismaFormat", () => { 209 | it("should convert input filters to prisma's filter format", () => { 210 | const inputFilter = [ 211 | { key: "a", operation: "like", value: "1" }, 212 | { key: "b", operation: "eq", value: "2" }, 213 | { key: "c", operation: "neq", value: "3" }, 214 | { key: "d", operation: "gt", value: "4" }, 215 | { key: "e", operation: "gte", value: "5" }, 216 | { key: "f", operation: "lt", value: "6" }, 217 | { key: "g", operation: "lte", value: "7" }, 218 | { key: "h", operation: "in", value: ["8"] }, 219 | { key: "i", operation: "notin", value: ["9"] }, 220 | { key: "j", operation: "isnull", value: "10" }, 221 | ]; 222 | 223 | const expectedOutput = { 224 | a: { contains: "1" }, 225 | b: { equals: "2" }, 226 | c: { not: { equals: "3" } }, 227 | d: { gt: "4" }, 228 | e: { gte: "5" }, 229 | f: { lt: "6" }, 230 | g: { lte: "7" }, 231 | h: { in: ["8"] }, 232 | i: { not: { in: ["9"] } }, 233 | j: { equals: null }, 234 | }; 235 | 236 | expect(service.convertFiltersToPrismaFormat(inputFilter)).toStrictEqual( 237 | expectedOutput, 238 | ); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /backend/src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PrismaService } from "src/prisma.service"; 3 | 4 | import { CreateUserDto } from "./dto/create-user.dto"; 5 | import { UpdateUserDto } from "./dto/update-user.dto"; 6 | import { UserDto } from "./dto/user.dto"; 7 | import { Prisma } from "@prisma/client"; 8 | 9 | @Injectable() 10 | export class UsersService { 11 | constructor( 12 | private prisma: PrismaService 13 | ) { 14 | } 15 | 16 | async create(user: CreateUserDto): Promise { 17 | const savedUser = await this.prisma.users.create({ 18 | data: { 19 | name: user.name, 20 | email: user.email 21 | } 22 | }); 23 | 24 | return { 25 | id: savedUser.id.toNumber(), 26 | name: savedUser.name, 27 | email: savedUser.email 28 | }; 29 | } 30 | 31 | async findAll(): Promise { 32 | const users = await this.prisma.users.findMany(); 33 | return users.flatMap(user => { 34 | const userDto: UserDto = { 35 | id: user.id.toNumber(), 36 | name: user.name, 37 | email: user.email 38 | }; 39 | return userDto; 40 | }); 41 | } 42 | 43 | async findOne(id: number): Promise { 44 | const user = await this.prisma.users.findUnique({ 45 | where: { 46 | id: new Prisma.Decimal(id) 47 | } 48 | }); 49 | return { 50 | id: user.id.toNumber(), 51 | name: user.name, 52 | email: user.email 53 | }; 54 | } 55 | 56 | async update(id: number, updateUserDto: UpdateUserDto): Promise { 57 | const user = await this.prisma.users.update({ 58 | where: { 59 | id: new Prisma.Decimal(id) 60 | }, 61 | data: { 62 | name: updateUserDto.name, 63 | email: updateUserDto.email 64 | } 65 | }); 66 | return { 67 | id: user.id.toNumber(), 68 | name: user.name, 69 | email: user.email 70 | }; 71 | } 72 | 73 | async remove(id: number): Promise<{ deleted: boolean; message?: string }> { 74 | try { 75 | await this.prisma.users.delete({ 76 | where: { 77 | id: new Prisma.Decimal(id) 78 | } 79 | }); 80 | return { deleted: true }; 81 | } catch (err) { 82 | return { deleted: false, message: err.message }; 83 | } 84 | } 85 | 86 | async searchUsers(page: number, 87 | limit: number, 88 | sort: string, // JSON string to store sort key and sort value, ex: [{"name":"desc"},{"email":"asc"}] 89 | filter: string // JSON array for key, operation and value, ex: [{"key": "name", "operation": "like", "value": "Jo"}] 90 | ): Promise { 91 | 92 | page = page || 1; 93 | if (!limit || limit > 200) { 94 | limit = 10; 95 | } 96 | 97 | let sortObj=[]; 98 | let filterObj = {}; 99 | try { 100 | sortObj = JSON.parse(sort); 101 | filterObj = JSON.parse(filter); 102 | } catch (e) { 103 | throw new Error("Invalid query parameters"); 104 | } 105 | const users = await this.prisma.users.findMany({ 106 | skip: (page - 1) * limit, 107 | take: parseInt(String(limit)), 108 | orderBy: sortObj, 109 | where: this.convertFiltersToPrismaFormat(filterObj) 110 | }); 111 | 112 | const count = await this.prisma.users.count({ 113 | orderBy: sortObj, 114 | where: this.convertFiltersToPrismaFormat(filterObj) 115 | }); 116 | 117 | return { 118 | users, 119 | page, 120 | limit, 121 | total: count, 122 | totalPages: Math.ceil(count / limit) 123 | }; 124 | } 125 | 126 | public convertFiltersToPrismaFormat(filterObj): any { 127 | 128 | let prismaFilterObj = {}; 129 | 130 | for (const item of filterObj) { 131 | 132 | if (item.operation === "like") { 133 | prismaFilterObj[item.key] = { contains: item.value }; 134 | } else if (item.operation === "eq") { 135 | prismaFilterObj[item.key] = { equals: item.value }; 136 | } else if (item.operation === "neq") { 137 | prismaFilterObj[item.key] = { not: { equals: item.value } }; 138 | } else if (item.operation === "gt") { 139 | prismaFilterObj[item.key] = { gt: item.value }; 140 | } else if (item.operation === "gte") { 141 | prismaFilterObj[item.key] = { gte: item.value }; 142 | } else if (item.operation === "lt") { 143 | prismaFilterObj[item.key] = { lt: item.value }; 144 | } else if (item.operation === "lte") { 145 | prismaFilterObj[item.key] = { lte: item.value }; 146 | } else if (item.operation === "in") { 147 | prismaFilterObj[item.key] = { in: item.value }; 148 | } else if (item.operation === "notin") { 149 | prismaFilterObj[item.key] = { not: { in: item.value } }; 150 | } else if (item.operation === "isnull") { 151 | prismaFilterObj[item.key] = { equals: null }; 152 | } 153 | } 154 | return prismaFilterObj; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { Test } from "@nestjs/testing"; 3 | import { INestApplication } from "@nestjs/common"; 4 | import { AppModule } from "../src/app.module"; 5 | 6 | describe("AppController (e2e)", () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const moduleFixture = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it("/ (GET)", () => 19 | request(app.getHttpServer()).get("/").expect(200).expect("Hello Backend!")); 20 | }); 21 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "moduleResolution": "NodeNext", 5 | "strict": false, 6 | "noUncheckedIndexedAccess": true, 7 | "moduleDetection": "force", 8 | "resolveJsonModule": true, 9 | "allowJs": true, 10 | "declaration": true, 11 | "removeComments": true, 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "allowSyntheticDefaultImports": true, 15 | "target": "es2022", 16 | "sourceMap": true, 17 | "outDir": "./dist", 18 | "baseUrl": "./", 19 | "incremental": true, 20 | "skipLibCheck": true, 21 | "lib": ["es2022"], 22 | "types": ["vitest/globals", "node"] 23 | }, 24 | "include": ["src/**/*.ts", "test/**/*.ts"], 25 | } 26 | -------------------------------------------------------------------------------- /backend/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import swc from "unplugin-swc"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | test: { 7 | include: ["**/*.e2e-spec.ts", "**/*.spec.ts"], 8 | exclude: ["**/node_modules/**"], 9 | globals: true, 10 | environment: "node", 11 | coverage: { 12 | provider: "v8", 13 | reporter: ["lcov", "text-summary", "text", "json", "html"], 14 | }, 15 | }, 16 | plugins: [swc.vite()], 17 | }); 18 | -------------------------------------------------------------------------------- /charts/app/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/app/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: postgresql 3 | repository: https://charts.bitnami.com/bitnami 4 | version: 16.6.6 5 | digest: sha256:405dfd82588dadde19b0915e844de5be337d44e6120f90524c645401934855e2 6 | generated: "2025-04-30T04:56:34.090515056Z" 7 | -------------------------------------------------------------------------------- /charts/app/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: quickstart-openshift 3 | description: A Helm chart for Kubernetes deployment. 4 | icon: https://www.nicepng.com/png/detail/521-5211827_bc-icon-british-columbia-government-logo.png 5 | 6 | # A chart can be either an 'application' or a 'library' chart. 7 | # 8 | # Application charts are a collection of templates that can be packaged into versioned archives 9 | # to be deployed. 10 | # 11 | # Library charts provide useful utilities or functions for the chart developer. They're included as 12 | # a dependency of application charts to inject those utilities and functions into the rendering 13 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 14 | type: application 15 | 16 | # This is the chart version. This version number should be incremented each time you make changes 17 | # to the chart and its templates, including the app version. 18 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 19 | version: 0.1.0 20 | 21 | # This is the version number of the application being deployed. This version number should be 22 | # incremented each time you make changes to the application. Versions are not expected to 23 | # follow Semantic Versioning. They should reflect the version the application is using. 24 | # It is recommended to use it with quotes. 25 | appVersion: "1.16.0" 26 | 27 | maintainers: 28 | - name: Om Mishra 29 | email: omprakash.2.mishra@gov.bc.ca 30 | - name: Derek Roberts 31 | email: derek.roberts@gov.bc.ca 32 | -------------------------------------------------------------------------------- /charts/app/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | {{ template "chart.header" . }} 2 | {{ template "chart.description" . }} 3 | 4 | {{ template "chart.versionBadge" . }}{{ template "chart.typeBadge" . }}{{ template "chart.appVersionBadge" . }} 5 | 6 | {{ template "chart.maintainersSection" . }} 7 | 8 | {{ template "chart.requirementsSection" . }} 9 | 10 | {{ template "chart.valuesTableHtml" . }} 11 | {{ template "chart.valuesSectionHtml" . }} 12 | {{ template "helm-docs.versionFooter" . }} 13 | -------------------------------------------------------------------------------- /charts/app/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} 18 | {{- end }} 19 | {{- end }} 20 | 21 | {{/* 22 | Create chart name and version as used by the chart label. 23 | */}} 24 | {{- define "name.chart" -}} 25 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 26 | {{- end }} 27 | 28 | {{/* 29 | Common labels 30 | */}} 31 | {{- define "labels" -}} 32 | helm.sh/chart: {{ include "name.chart" . }} 33 | {{ include "selectorLabels" . }} 34 | {{- if .Chart.AppVersion }} 35 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 36 | {{- end }} 37 | app.kubernetes.io/managed-by: {{ .Release.Service }} 38 | {{- end }} 39 | 40 | {{/* 41 | Selector labels 42 | */}} 43 | {{- define "selectorLabels" -}} 44 | app.kubernetes.io/name: {{ include "fullname" . }} 45 | app.kubernetes.io/instance: {{ .Release.Name }} 46 | {{- end }} 47 | -------------------------------------------------------------------------------- /charts/app/templates/backend/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "backend.name" -}} 5 | {{- printf "backend" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "backend.fullname" -}} 14 | {{- $componentName := include "backend.name" . }} 15 | {{- if .Values.backend.fullnameOverride }} 16 | {{- .Values.backend.fullnameOverride | trunc 63 | trimSuffix "-" }} 17 | {{- else }} 18 | {{- printf "%s-%s" .Release.Name $componentName | trunc 63 | trimSuffix "-" }} 19 | {{- end }} 20 | {{- end }} 21 | 22 | {{/* 23 | Common labels 24 | */}} 25 | {{- define "backend.labels" -}} 26 | {{ include "backend.selectorLabels" . }} 27 | {{- if .Values.global.tag }} 28 | app.kubernetes.io/image-version: {{ .Values.global.tag | quote }} 29 | {{- end }} 30 | app.kubernetes.io/managed-by: {{ .Release.Service }} 31 | app.kubernetes.io/short-name: {{ include "backend.name" . }} 32 | {{- end }} 33 | 34 | {{/* 35 | Selector labels 36 | */}} 37 | {{- define "backend.selectorLabels" -}} 38 | app.kubernetes.io/name: {{ include "backend.name" . }} 39 | app.kubernetes.io/instance: {{ .Release.Name }} 40 | {{- end }} 41 | 42 | 43 | -------------------------------------------------------------------------------- /charts/app/templates/backend/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.backend.enabled }} 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "backend.fullname" . }} 6 | labels: 7 | {{- include "backend.labels" . | nindent 4 }} 8 | spec: 9 | strategy: 10 | type: {{ .Values.backend.deploymentStrategy }} 11 | {{- if not .Values.backend.autoscaling.enabled }} 12 | replicas: {{ .Values.backend.replicaCount }} 13 | {{- end }} 14 | selector: 15 | matchLabels: 16 | {{- include "backend.selectorLabels" . | nindent 6 }} 17 | template: 18 | metadata: 19 | annotations: 20 | rollme: {{ randAlphaNum 5 | quote }} 21 | prometheus.io/scrape: 'true' 22 | prometheus.io/port: '3000' 23 | prometheus.io/path: '/api/metrics' 24 | labels: 25 | {{- include "backend.labels" . | nindent 8 }} 26 | spec: 27 | {{- if .Values.backend.podSecurityContext }} 28 | securityContext: 29 | {{- toYaml .Values.backend.podSecurityContext | nindent 12 }} 30 | {{- end }} 31 | initContainers: 32 | - name: {{ include "backend.fullname" . }}-init 33 | image: "{{.Values.global.registry}}/{{.Values.global.repository}}/migrations:{{ .Values.global.tag | default .Chart.AppVersion }}" 34 | imagePullPolicy: {{ default "Always" .Values.backend.imagePullPolicy }} 35 | envFrom: 36 | - secretRef: 37 | name: {{.Release.Name}}-flyway 38 | env: 39 | - name: FLYWAY_BASELINE_ON_MIGRATE 40 | value: "true" 41 | - name: FLYWAY_DEFAULT_SCHEMA 42 | value: "USERS" 43 | - name: FLYWAY_CONNECT_RETRIES 44 | value: "10" 45 | - name: FLYWAY_GROUP 46 | value: "true" 47 | resources: 48 | requests: 49 | cpu: 50m 50 | memory: 75Mi 51 | containers: 52 | - name: {{ include "backend.fullname" . }} 53 | {{- if .Values.backend.securityContext }} 54 | securityContext: 55 | {{- toYaml .Values.backend.securityContext | nindent 12 }} 56 | {{- end }} 57 | image: "{{.Values.global.registry}}/{{.Values.global.repository}}/backend:{{ .Values.global.tag | default .Chart.AppVersion }}" 58 | imagePullPolicy: {{ default "Always" .Values.backend.imagePullPolicy }} 59 | envFrom: 60 | - secretRef: 61 | name: {{.Release.Name}}-backend 62 | env: 63 | - name: LOG_LEVEL 64 | value: info 65 | ports: 66 | - name: http 67 | containerPort: {{ .Values.backend.service.targetPort }} 68 | protocol: TCP 69 | readinessProbe: 70 | httpGet: 71 | path: /api/health 72 | port: http 73 | scheme: HTTP 74 | initialDelaySeconds: 5 75 | periodSeconds: 2 76 | timeoutSeconds: 2 77 | successThreshold: 1 78 | failureThreshold: 30 79 | livenessProbe: 80 | successThreshold: 1 81 | failureThreshold: 3 82 | httpGet: 83 | path: /api/health 84 | port: 3000 85 | scheme: HTTP 86 | initialDelaySeconds: 15 87 | periodSeconds: 30 88 | timeoutSeconds: 5 89 | resources: # this is optional 90 | requests: 91 | cpu: 50m 92 | memory: 75Mi 93 | {{- with .Values.backend.nodeSelector }} 94 | nodeSelector: 95 | {{- toYaml . | nindent 8 }} 96 | {{- end }} 97 | {{- with .Values.backend.tolerations }} 98 | tolerations: 99 | {{- toYaml . | nindent 8 }} 100 | {{- end }} 101 | affinity: 102 | podAntiAffinity: 103 | requiredDuringSchedulingIgnoredDuringExecution: 104 | - labelSelector: 105 | matchExpressions: 106 | - key: app.kubernetes.io/name 107 | operator: In 108 | values: 109 | - {{ include "backend.fullname" . }} 110 | - key: app.kubernetes.io/instance 111 | operator: In 112 | values: 113 | - {{ .Release.Name }} 114 | topologyKey: "kubernetes.io/hostname" 115 | 116 | {{- end }} 117 | -------------------------------------------------------------------------------- /charts/app/templates/backend/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.global.autoscaling }} 2 | {{- if and .Values.backend.autoscaling .Values.backend.autoscaling.enabled }} 3 | apiVersion: autoscaling/v2 4 | kind: HorizontalPodAutoscaler 5 | metadata: 6 | name: {{ include "backend.fullname" . }} 7 | labels: 8 | {{- include "backend.labels" . | nindent 4 }} 9 | spec: 10 | scaleTargetRef: 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | name: {{ include "backend.fullname" . }} 14 | minReplicas: {{ .Values.backend.autoscaling.minReplicas }} 15 | maxReplicas: {{ .Values.backend.autoscaling.maxReplicas }} 16 | behavior: 17 | scaleDown: 18 | stabilizationWindowSeconds: 300 19 | policies: 20 | - type: Percent 21 | value: 10 22 | periodSeconds: 60 23 | - type: Pods 24 | value: 2 25 | periodSeconds: 60 26 | selectPolicy: Min 27 | scaleUp: 28 | stabilizationWindowSeconds: 0 29 | policies: 30 | - type: Percent 31 | value: 100 32 | periodSeconds: 30 33 | - type: Pods 34 | value: 2 35 | periodSeconds: 30 36 | selectPolicy: Max 37 | metrics: 38 | {{- if .Values.backend.autoscaling.targetCPUUtilizationPercentage }} 39 | - type: Resource 40 | resource: 41 | name: cpu 42 | target: 43 | type: Utilization 44 | averageUtilization: {{ .Values.backend.autoscaling.targetCPUUtilizationPercentage }} 45 | {{- end }} 46 | {{- if .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} 47 | - type: Resource 48 | resource: 49 | name: memory 50 | target: 51 | type: Utilization 52 | averageUtilization: {{ .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} 53 | {{- end }} 54 | {{- end }} 55 | {{- end }} 56 | -------------------------------------------------------------------------------- /charts/app/templates/backend/templates/pdb.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.backend.pdb .Values.backend.pdb.enabled }} 2 | --- 3 | apiVersion: policy/v1 4 | kind: PodDisruptionBudget 5 | metadata: 6 | name: {{ include "backend.fullname" . }} 7 | labels: 8 | {{- include "backend.labels" . | nindent 4 }} 9 | spec: 10 | selector: 11 | matchLabels: 12 | {{- include "backend.selectorLabels" . | nindent 6 }} 13 | minAvailable: {{ .Values.backend.pdb.minAvailable }} 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /charts/app/templates/backend/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.backend.enabled }} 2 | --- 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: {{ include "backend.fullname" . }} 7 | labels: 8 | {{- include "backend.labels" . | nindent 4 }} 9 | spec: 10 | type: {{ .Values.backend.service.type }} 11 | ports: 12 | - port: {{ .Values.backend.service.port }} 13 | targetPort: http 14 | protocol: TCP 15 | name: http 16 | selector: 17 | {{- include "backend.selectorLabels" . | nindent 4 }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /charts/app/templates/frontend/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "frontend.name" -}} 5 | {{- printf "frontend" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "frontend.fullname" -}} 14 | {{- $componentName := include "frontend.name" . }} 15 | {{- if .Values.frontend.fullnameOverride }} 16 | {{- .Values.frontend.fullnameOverride | trunc 63 | trimSuffix "-" }} 17 | {{- else }} 18 | {{- printf "%s-%s" .Release.Name $componentName | trunc 63 | trimSuffix "-" }} 19 | {{- end }} 20 | {{- end }} 21 | 22 | 23 | {{/* 24 | Common labels 25 | */}} 26 | {{- define "frontend.labels" -}} 27 | {{ include "frontend.selectorLabels" . }} 28 | {{- if .Values.global.tag }} 29 | app.kubernetes.io/image-version: {{ .Values.global.tag | quote }} 30 | {{- end }} 31 | app.kubernetes.io/managed-by: {{ .Release.Service }} 32 | app.kubernetes.io/short-name: {{ include "frontend.name" . }} 33 | {{- end }} 34 | 35 | {{/* 36 | Selector labels 37 | */}} 38 | {{- define "frontend.selectorLabels" -}} 39 | app.kubernetes.io/name: {{ include "frontend.name" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- end }} 42 | 43 | 44 | -------------------------------------------------------------------------------- /charts/app/templates/frontend/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.frontend.enabled }} 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "frontend.fullname" . }} 6 | labels: 7 | {{- include "frontend.labels" . | nindent 4 }} 8 | spec: 9 | strategy: 10 | type: {{ .Values.frontend.deploymentStrategy }} 11 | {{- if not .Values.frontend.autoscaling.enabled }} 12 | replicas: {{ .Values.frontend.replicaCount }} 13 | {{- end }} 14 | selector: 15 | matchLabels: 16 | {{- include "frontend.selectorLabels" . | nindent 6 }} 17 | template: 18 | metadata: 19 | annotations: 20 | rollme: {{ randAlphaNum 5 | quote }} 21 | prometheus.io/scrape: 'true' 22 | prometheus.io/port: '3003' 23 | prometheus.io/path: '/metrics' 24 | labels: 25 | {{- include "frontend.labels" . | nindent 8 }} 26 | spec: 27 | {{- if .Values.frontend.podSecurityContext }} 28 | securityContext: 29 | {{- toYaml .Values.frontend.podSecurityContext | nindent 12 }} 30 | {{- end }} 31 | containers: 32 | - name: {{ include "frontend.fullname" . }} 33 | securityContext: 34 | capabilities: 35 | add: [ "NET_BIND_SERVICE" ] 36 | image: "{{.Values.global.registry}}/{{.Values.global.repository}}/frontend:{{ .Values.global.tag | default .Chart.AppVersion }}" 37 | imagePullPolicy: {{ default "Always" .Values.frontend.imagePullPolicy }} 38 | env: 39 | - name: BACKEND_URL 40 | value: "http://{{ .Release.Name }}-backend" 41 | - name: LOG_LEVEL 42 | value: "info" 43 | ports: 44 | - name: http 45 | containerPort: 3000 46 | protocol: TCP 47 | readinessProbe: 48 | httpGet: 49 | path: /health 50 | port: 3001 51 | scheme: HTTP 52 | initialDelaySeconds: 5 53 | periodSeconds: 2 54 | timeoutSeconds: 2 55 | successThreshold: 1 56 | failureThreshold: 30 57 | #-- the liveness probe for the container. it is optional and is an object. for default values check this link: https://github.com/bcgov/helm-service/blob/main/charts/component/templates/deployment.yaml#L324-L328 58 | livenessProbe: 59 | successThreshold: 1 60 | failureThreshold: 3 61 | httpGet: 62 | path: /health 63 | port: 3001 64 | scheme: HTTP 65 | initialDelaySeconds: 15 66 | periodSeconds: 30 67 | timeoutSeconds: 5 68 | resources: 69 | requests: 70 | cpu: 30m 71 | memory: 50Mi 72 | volumeMounts: 73 | - name: data 74 | mountPath: /data 75 | - name: config 76 | mountPath: /config 77 | volumes: 78 | - name: data 79 | emptyDir: {} 80 | - name: config 81 | emptyDir: {} 82 | affinity: 83 | podAntiAffinity: 84 | requiredDuringSchedulingIgnoredDuringExecution: 85 | - labelSelector: 86 | matchExpressions: 87 | - key: app.kubernetes.io/name 88 | operator: In 89 | values: 90 | - {{ include "frontend.fullname" . }} 91 | - key: app.kubernetes.io/instance 92 | operator: In 93 | values: 94 | - {{ .Release.Name }} 95 | topologyKey: "kubernetes.io/hostname" 96 | 97 | {{- end }} 98 | -------------------------------------------------------------------------------- /charts/app/templates/frontend/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.global.autoscaling }} 2 | {{- if and .Values.frontend.autoscaling .Values.frontend.autoscaling.enabled }} 3 | apiVersion: autoscaling/v2 4 | kind: HorizontalPodAutoscaler 5 | metadata: 6 | name: {{ include "frontend.fullname" . }} 7 | labels: 8 | {{- include "frontend.labels" . | nindent 4 }} 9 | spec: 10 | scaleTargetRef: 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | name: {{ include "frontend.fullname" . }} 14 | minReplicas: {{ .Values.frontend.autoscaling.minReplicas }} 15 | maxReplicas: {{ .Values.frontend.autoscaling.maxReplicas }} 16 | behavior: 17 | scaleDown: 18 | stabilizationWindowSeconds: 300 19 | policies: 20 | - type: Percent 21 | value: 10 22 | periodSeconds: 60 23 | - type: Pods 24 | value: 2 25 | periodSeconds: 60 26 | selectPolicy: Min 27 | scaleUp: 28 | stabilizationWindowSeconds: 0 29 | policies: 30 | - type: Percent 31 | value: 100 32 | periodSeconds: 30 33 | - type: Pods 34 | value: 2 35 | periodSeconds: 30 36 | selectPolicy: Max 37 | metrics: 38 | {{- if .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} 39 | - type: Resource 40 | resource: 41 | name: cpu 42 | target: 43 | type: Utilization 44 | averageUtilization: {{ .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} 45 | {{- end }} 46 | {{- if .Values.frontend.autoscaling.targetMemoryUtilizationPercentage }} 47 | - type: Resource 48 | resource: 49 | name: memory 50 | target: 51 | type: Utilization 52 | averageUtilization: {{ .Values.frontend.autoscaling.targetMemoryUtilizationPercentage }} 53 | {{- end }} 54 | {{- end }} 55 | {{- end }} 56 | -------------------------------------------------------------------------------- /charts/app/templates/frontend/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.frontend.enabled }} 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: {{ include "frontend.fullname" . }} 6 | labels: 7 | {{- include "frontend.labels" . | nindent 4 }} 8 | {{- if and .Values.frontend.ingress .Values.frontend.ingress.annotations }} 9 | {{- with .Values.frontend.ingress.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- end }} 14 | spec: 15 | ingressClassName: openshift-default 16 | rules: 17 | - host: {{ include "frontend.fullname" . }}.{{ .Values.global.domain }} 18 | http: 19 | paths: 20 | - path: / 21 | pathType: ImplementationSpecific 22 | backend: 23 | service: 24 | name: {{ include "frontend.fullname" . }} 25 | port: 26 | number: 80 27 | {{- end }} 28 | -------------------------------------------------------------------------------- /charts/app/templates/frontend/templates/pdb.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.frontend.pdb .Values.frontend.pdb.enabled }} 2 | --- 3 | apiVersion: policy/v1 4 | kind: PodDisruptionBudget 5 | metadata: 6 | name: {{ include "frontend.fullname" . }} 7 | labels: 8 | {{- include "frontend.labels" . | nindent 4 }} 9 | spec: 10 | selector: 11 | matchLabels: 12 | {{- include "frontend.selectorLabels" . | nindent 6 }} 13 | minAvailable: {{ .Values.frontend.pdb.minAvailable }} 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /charts/app/templates/frontend/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.frontend.enabled }} 2 | --- 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: {{ include "frontend.fullname" . }} 7 | labels: 8 | {{- include "frontend.labels" . | nindent 4 }} 9 | spec: 10 | type: {{ .Values.frontend.service.type }} 11 | ports: 12 | - name: http 13 | #-- the port for the service. the service will be accessible on this port within the namespace. 14 | port: 80 15 | #-- the container port where the application is listening on 16 | targetPort: 3000 17 | #-- the protocol for the port. it can be TCP or UDP. TCP is the default and is recommended. 18 | protocol: TCP 19 | - port: 3003 20 | targetPort: 3003 21 | protocol: TCP 22 | name: metrics 23 | selector: 24 | {{- include "frontend.selectorLabels" . | nindent 4 }} 25 | {{- end }} 26 | -------------------------------------------------------------------------------- /charts/app/templates/knp.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: NetworkPolicy 4 | metadata: 5 | name: {{ .Release.Name }}-openshift-ingress-to-frontend 6 | labels: {{- include "selectorLabels" . | nindent 4 }} 7 | spec: 8 | podSelector: 9 | matchLabels: 10 | app.kubernetes.io/name: frontend 11 | app.kubernetes.io/instance: {{ .Release.Name }} 12 | ingress: 13 | - from: 14 | - namespaceSelector: 15 | matchLabels: 16 | network.openshift.io/policy-group: ingress 17 | policyTypes: 18 | - Ingress 19 | --- 20 | apiVersion: networking.k8s.io/v1 21 | kind: NetworkPolicy 22 | metadata: 23 | name: {{ .Release.Name }}-allow-backend-to-db 24 | labels: {{- include "selectorLabels" . | nindent 4 }} 25 | spec: 26 | podSelector: 27 | matchLabels: 28 | postgres-operator.crunchydata.com/cluster: {{ .Values.global.databaseAlias}} 29 | ingress: 30 | - ports: 31 | - protocol: TCP 32 | port: 5432 33 | from: 34 | - podSelector: 35 | matchLabels: 36 | app.kubernetes.io/name: {{ template "backend.name"}} 37 | app.kubernetes.io/instance: {{ .Release.Name }} 38 | policyTypes: 39 | - Ingress 40 | --- 41 | apiVersion: networking.k8s.io/v1 42 | kind: NetworkPolicy 43 | metadata: 44 | name: {{ .Release.Name }}-allow-frontend-to-backend 45 | labels: {{- include "selectorLabels" . | nindent 4 }} 46 | spec: 47 | podSelector: 48 | matchLabels: 49 | app.kubernetes.io/name: {{ template "backend.name"}} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | ingress: 52 | - ports: 53 | - protocol: TCP 54 | port: 3000 55 | from: 56 | - podSelector: 57 | matchLabels: 58 | app.kubernetes.io/name: {{ template "frontend.name"}} 59 | app.kubernetes.io/instance: {{ .Release.Name }} 60 | policyTypes: 61 | - Ingress 62 | -------------------------------------------------------------------------------- /charts/app/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.global.secrets .Values.global.secrets.enabled}} 2 | {{- $databaseUser := printf ""}} 3 | {{- $databasePassword := printf ""}} 4 | {{- $host := printf ""}} 5 | {{- $databaseName := printf ""}} 6 | {{- $hostWithoutPort := printf ""}} 7 | {{- $secretName := printf "%s-pguser-%s" .Values.global.databaseAlias .Values.global.config.databaseUser }} 8 | {{- $databaseUser = .Values.global.config.databaseUser}} 9 | {{- $secretObj := (lookup "v1" "Secret" .Release.Namespace $secretName ) }} 10 | {{- if not $secretObj }} 11 | {{- fail (printf "Secret %s not found in namespace %s" $secretName .Release.Namespace) }} 12 | {{- end }} 13 | {{- $secretData := (get $secretObj "data") }} 14 | {{- if not $secretData }} 15 | {{- fail (printf "Secret %s data not found in namespace %s" $secretName .Release.Namespace) }} 16 | {{- end }} 17 | {{- $databasePassword = get $secretData "password" }} 18 | {{- $databaseName = b64dec (get $secretData "dbname") }} 19 | {{- $host = printf "%s:%s" (b64dec (get $secretData "host")) (b64dec (get $secretData "port")) }} 20 | {{- $hostWithoutPort = printf "%s" (b64dec (get $secretData "host")) }} 21 | {{- $databaseURL := printf "postgresql://%s:%s@%s/%s" $databaseUser (b64dec $databasePassword) $host $databaseName }} 22 | {{- $databaseJDBCURL := printf "jdbc:postgresql://%s:%s@%s/%s" $databaseUser (b64dec $databasePassword) $host $databaseName }} 23 | {{- $databaseJDBCURLNoCreds := printf "jdbc:postgresql://%s/%s" $host $databaseName }} 24 | 25 | --- 26 | apiVersion: v1 27 | kind: Secret 28 | metadata: 29 | name: {{ .Release.Name }}-backend 30 | labels: {{- include "labels" . | nindent 4 }} 31 | {{- if .Values.global.secrets.persist }} 32 | annotations: 33 | helm.sh/resource-policy: keep 34 | {{- end }} 35 | data: 36 | POSTGRES_PASSWORD: {{ $databasePassword | quote }} 37 | POSTGRES_USER: {{ $databaseUser | b64enc | quote }} 38 | POSTGRES_DATABASE: {{ $databaseName | b64enc | quote }} 39 | POSTGRES_HOST: {{ $hostWithoutPort | b64enc | quote }} 40 | 41 | --- 42 | apiVersion: v1 43 | kind: Secret 44 | metadata: 45 | name: {{ .Release.Name }}-flyway 46 | labels: {{- include "labels" . | nindent 4 }} 47 | {{- if .Values.global.secrets.persist }} 48 | annotations: 49 | helm.sh/resource-policy: keep 50 | {{- end }} 51 | data: 52 | FLYWAY_URL: {{ $databaseJDBCURLNoCreds | b64enc | quote }} 53 | FLYWAY_USER: {{ $databaseUser | b64enc | quote }} 54 | FLYWAY_PASSWORD: {{ $databasePassword | quote }} 55 | {{- end }} 56 | -------------------------------------------------------------------------------- /charts/app/values.yaml: -------------------------------------------------------------------------------- 1 | # This is a YAML-formatted file. 2 | # Declare variables to be passed into your templates. 3 | #-- global variables, can be accessed by sub-charts. 4 | global: 5 | #-- the registry where the images are stored. override during runtime for other registry at global level or individual level. 6 | repository: ~ # provide the repo name from where images will be sourced for example bcgo 7 | #-- the registry where the images are stored. override during runtime for other registry at global level or individual level. default is ghcr.io 8 | registry: ghcr.io # ghcr.io for directly streaming from github container registry or "artifacts.developer.gov.bc.ca/github-docker-remote" for artifactory, or any other registry. 9 | #-- the tag of the image, it can be latest, 1.0.0 etc..., or the sha256 hash 10 | tag: ~ 11 | #-- turn off autoscaling for the entire suite by setting this to false. default is true. 12 | autoscaling: false 13 | #-- global secrets, can be accessed by sub-charts. 14 | secrets: 15 | enabled: true 16 | databasePassword: ~ 17 | databaseName: ~ 18 | persist: true 19 | config: 20 | databaseUser: ~ 21 | #-- domain of the application, it is required, apps.silver.devops.gov.bc.ca for silver cluster and apps.devops.gov.bc.ca for gold cluster 22 | domain: "apps.silver.devops.gov.bc.ca" # it is apps.gold.devops.gov.bc.ca for gold cluster 23 | databaseAlias: ~ # set using github action workflow during helm deploy. 24 | 25 | #-- the components of the application, backend. 26 | backend: 27 | #-- enable or disable backend 28 | enabled: true 29 | #-- the deployment strategy, can be "Recreate" or "RollingUpdate" 30 | deploymentStrategy: Recreate 31 | #-- autoscaling for the component. it is optional and is an object. 32 | autoscaling: 33 | #-- enable or disable autoscaling. 34 | enabled: true 35 | #-- the minimum number of replicas. 36 | minReplicas: 3 37 | #-- the maximum number of replicas. 38 | maxReplicas: 7 39 | #-- the target cpu utilization percentage, is from request cpu and NOT LIMIT CPU. 40 | targetCPUUtilizationPercentage: 80 41 | #-- vault, for injecting secrets from vault. it is optional and is an object. it creates an initContainer which reads from vault and app container can source those secrets. for referring to a working example with vault follow this link: https://github.com/bcgov/onroutebc/blob/main/charts/onroutebc/values.yaml#L171-L186 42 | vault: 43 | #-- enable or disable vault. 44 | enabled: false 45 | #-- the role of the vault. it is required, #licenseplate-prod or licenseplate-nonprod, license plate is the namespace without env 46 | role: ~ 47 | #-- the vault path where the secrets live. it is required, dev/api-1, dev/api-2, test/api-1 etc... 48 | secretPaths: 49 | - dev/api-1 50 | - dev/api-2 51 | - test/api-1 52 | - test/api-2 53 | - prod/api-1 54 | - prod/api-2 55 | #-- resources specific to vault initContainer. it is optional and is an object. 56 | resources: 57 | requests: 58 | cpu: 50m 59 | memory: 25Mi 60 | #-- the service for the component. for inter namespace communication, use the service name as the hostname. 61 | service: 62 | #-- the type of the service. it can be ClusterIP, NodePort, LoadBalancer, ExternalName. ClusterIP is the default and is recommended. 63 | type: ClusterIP 64 | port: 80 # this is the service port, where it will be exposed internal to the namespace. 65 | targetPort: 3000 # this is container port where app listens on 66 | pdb: 67 | enabled: false # enable it in PRODUCTION for having pod disruption budget. 68 | minAvailable: 1 # the minimum number of pods that must be available during the disruption budget. 69 | 70 | frontend: 71 | # -- enable or disable a component deployment. 72 | enabled: true 73 | # -- the deployment strategy, can be "Recreate" or "RollingUpdate" 74 | deploymentStrategy: Recreate 75 | 76 | #-- autoscaling for the component. it is optional and is an object. 77 | autoscaling: 78 | #-- enable or disable autoscaling. 79 | enabled: true 80 | #-- the minimum number of replicas. 81 | minReplicas: 3 82 | #-- the maximum number of replicas. 83 | maxReplicas: 7 84 | #-- the target cpu utilization percentage, is from request cpu and NOT LIMIT CPU. 85 | targetCPUUtilizationPercentage: 80 86 | #-- the service for the component. for inter namespace communication, use the service name as the hostname. 87 | service: 88 | #-- enable or disable the service. 89 | enabled: true 90 | #-- the type of the service. it can be ClusterIP, NodePort, LoadBalancer, ExternalName. ClusterIP is the default and is recommended. 91 | type: ClusterIP 92 | #-- the ports for the service. 93 | ports: 94 | - name: http 95 | #-- the port for the service. the service will be accessible on this port within the namespace. 96 | port: 80 97 | #-- the container port where the application is listening on 98 | targetPort: 3000 99 | #-- the protocol for the port. it can be TCP or UDP. TCP is the default and is recommended. 100 | protocol: TCP 101 | - port: 3003 102 | targetPort: 3003 103 | protocol: TCP 104 | name: metrics 105 | ingress: 106 | annotations: 107 | haproxy.router.openshift.io/balance: "roundrobin" 108 | route.openshift.io/termination: "edge" 109 | haproxy.router.openshift.io/rate-limit-connections: "true" 110 | haproxy.router.openshift.io/rate-limit-connections.concurrent-tcp: "10" 111 | haproxy.router.openshift.io/rate-limit-connections.rate-http: "20" 112 | haproxy.router.openshift.io/rate-limit-connections.rate-tcp: "50" 113 | haproxy.router.openshift.io/disable_cookies: "true" 114 | pdb: 115 | enabled: false # enable it in PRODUCTION for having pod disruption budget. 116 | minAvailable: 1 # the minimum number of pods that must be available during the disruption budget. 117 | -------------------------------------------------------------------------------- /charts/crunchy/values.yml: -------------------------------------------------------------------------------- 1 | # Values from bcgov/quickstart-openshift 2 | global: 3 | config: 4 | dbName: app #test 5 | crunchy: # enable it for TEST and PROD, for PR based pipelines simply use single postgres 6 | enabled: true 7 | postgresVersion: 17 8 | postGISVersion: 3.4 9 | imagePullPolicy: IfNotPresent 10 | # enable below to start a new crunchy cluster after disaster from a backed-up location, crunchy will choose the best place to recover from. 11 | # follow https://access.crunchydata.com/documentation/postgres-operator/5.2.0/tutorial/disaster-recovery/ 12 | # Clone From Backups Stored in S3 / GCS / Azure Blob Storage 13 | clone: 14 | enabled: false 15 | s3: 16 | enabled: false 17 | pvc: 18 | enabled: false 19 | path: ~ # provide the proper path to source the cluster. ex: /backups/cluster/version/1, if current new cluster being created, this should be current cluster version -1, ideally 20 | # enable this to go back to a specific timestamp in history in the current cluster. 21 | # follow https://access.crunchydata.com/documentation/postgres-operator/5.2.0/tutorial/disaster-recovery/ 22 | # Perform an In-Place Point-in-time-Recovery (PITR) 23 | restore: 24 | repoName: ~ # provide repo name 25 | enabled: false 26 | target: ~ # 2024-03-24 17:16:00-07 this is the target timestamp to go back to in current cluster 27 | instances: 28 | name: db # high availability 29 | replicas: 2 # 2 or 3 for high availability in TEST and PROD. 30 | metadata: 31 | annotations: 32 | prometheus.io/scrape: 'true' 33 | prometheus.io/port: '9187' 34 | dataVolumeClaimSpec: 35 | storage: 150Mi 36 | storageClassName: netapp-block-standard 37 | walStorage: 300Mi 38 | 39 | requests: 40 | cpu: 50m 41 | memory: 128Mi 42 | replicaCertCopy: 43 | requests: 44 | cpu: 1m 45 | memory: 32Mi 46 | 47 | pgBackRest: 48 | enabled: true 49 | backupPath: /backups/test/cluster/version # change it for PROD, create values-prod.yaml # this is only used in s3 backups context. 50 | clusterCounter: 1 # this is the number to identify what is the current counter for the cluster, each time it is cloned it should be incremented. 51 | # If retention-full-type set to 'count' then the oldest backups will expire when the number of backups reach the number defined in retention 52 | # If retention-full-type set to 'time' then the number defined in retention will take that many days worth of full backups before expiration 53 | retentionFullType: count 54 | s3: 55 | enabled: false # if enabled, below must be provided 56 | retention: 7 # one weeks backup in object store. 57 | bucket: ~ 58 | endpoint: ~ 59 | accessKey: ~ 60 | secretKey: ~ 61 | fullBackupSchedule: ~ # make sure to provide values here, if s3 is enabled. 62 | incrementalBackupSchedule: ~ # make sure to provide values here, if s3 is enabled. 63 | pvc: 64 | retention: 1 # one day hot active backup in pvc 65 | retentionFullType: count 66 | fullBackupSchedule: 0 8 * * * 67 | incrementalBackupSchedule: 0 0-7,9-23 * * * # every hour incremental 68 | volume: 69 | accessModes: "ReadWriteOnce" 70 | storage: 100Mi 71 | storageClassName: netapp-file-backup 72 | 73 | config: 74 | requests: 75 | cpu: 5m 76 | memory: 32Mi 77 | repoHost: 78 | requests: 79 | cpu: 20m 80 | memory: 128Mi 81 | sidecars: 82 | requests: 83 | cpu: 5m 84 | memory: 16Mi 85 | jobs: 86 | requests: 87 | cpu: 20m 88 | memory: 128Mi 89 | 90 | patroni: 91 | postgresql: 92 | pg_hba: 93 | - "host all all 0.0.0.0/0 md5" 94 | - "host all all ::1/128 md5" 95 | parameters: 96 | shared_buffers: 16MB # default is 128MB; a good tuned default for shared_buffers is 25% of the memory allocated to the pod 97 | wal_buffers: "64kB" # this can be set to -1 to automatically set as 1/32 of shared_buffers or 64kB, whichever is larger 98 | min_wal_size: 32MB 99 | max_wal_size: 64MB # default is 1GB 100 | max_slot_wal_keep_size: 128MB # default is -1, allowing unlimited wal growth when replicas fall behind 101 | work_mem: 2MB # a work_mem value of 2 MB 102 | log_min_duration_statement: 1000ms # log queries taking more than 1 second to respond. 103 | effective_io_concurrency: 20 #If the underlying disk can handle multiple simultaneous requests, then you should increase the effective_io_concurrency value and test what value provides the best application performance. All BCGov clusters have SSD. 104 | 105 | proxy: 106 | enabled: true 107 | pgBouncer: 108 | image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default 109 | replicas: 1 110 | requests: 111 | cpu: 5m 112 | memory: 32Mi 113 | maxConnections: 10 # make sure less than postgres max connections 114 | 115 | # Postgres Cluster resource values: 116 | pgmonitor: 117 | enabled: true 118 | exporter: 119 | image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default 120 | requests: 121 | cpu: 10m 122 | memory: 32Mi 123 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Reusable vars 2 | x-var: 3 | - &POSTGRES_USER 4 | postgres 5 | - &POSTGRES_PASSWORD 6 | default 7 | - &POSTGRES_DATABASE 8 | postgres 9 | - &node-image 10 | node:22 11 | 12 | # Reusable envars for postgres 13 | x-postgres-vars: &postgres-vars 14 | POSTGRES_HOST: database 15 | POSTGRES_USER: *POSTGRES_USER 16 | POSTGRES_PASSWORD: *POSTGRES_PASSWORD 17 | POSTGRES_DATABASE: *POSTGRES_DATABASE 18 | 19 | services: 20 | database: 21 | image: postgis/postgis:16-3.4 # if using crunchy , make sure to align with crunchy version, currently it is at 16 and postgis 3.3 22 | container_name: database 23 | environment: 24 | <<: *postgres-vars 25 | healthcheck: 26 | test: ["CMD", "pg_isready", "-U", *POSTGRES_USER] 27 | ports: ["5432:5432"] 28 | 29 | migrations: 30 | image: flyway/flyway:10-alpine 31 | container_name: migrations 32 | command: info migrate info 33 | volumes: ["./migrations/sql:/flyway/sql:ro"] 34 | environment: 35 | FLYWAY_URL: jdbc:postgresql://database:5432/postgres 36 | FLYWAY_USER: *POSTGRES_USER 37 | FLYWAY_PASSWORD: *POSTGRES_PASSWORD 38 | FLYWAY_BASELINE_ON_MIGRATE: true 39 | FLYWAY_DEFAULT_SCHEMA: users 40 | depends_on: 41 | database: 42 | condition: service_healthy 43 | 44 | schemaspy: 45 | image: schemaspy/schemaspy:6.2.4 46 | profiles: ["schemaspy"] 47 | container_name: schemaspy 48 | command: -t pgsql11 -db postgres -host database -port 5432 -u postgres -p default -schemas users 49 | depends_on: 50 | migrations: 51 | condition: service_completed_successfully 52 | volumes: ["./output:/output"] 53 | 54 | backend: 55 | container_name: backend 56 | depends_on: 57 | migrations: 58 | condition: service_started 59 | entrypoint: sh -c "npm i && npm run start:dev" 60 | environment: 61 | <<: *postgres-vars 62 | NODE_ENV: development 63 | image: *node-image 64 | ports: ["3001:3000"] 65 | healthcheck: 66 | test: ["CMD", "curl", "-f", "http://localhost:3000/api"] 67 | working_dir: "/app" 68 | volumes: ["./backend:/app", "/app/node_modules"] 69 | 70 | frontend: 71 | container_name: frontend 72 | entrypoint: sh -c "npm ci && npm run dev" 73 | environment: 74 | BACKEND_URL: http://backend:3000 75 | PORT: 3000 76 | NODE_ENV: development 77 | image: *node-image 78 | ports: ["3000:3000"] 79 | volumes: ["./frontend:/app", "/app/node_modules"] 80 | healthcheck: 81 | test: ["CMD", "curl", "-f", "http://localhost:3000"] 82 | working_dir: "/app" 83 | depends_on: 84 | backend: 85 | condition: service_healthy 86 | 87 | caddy: 88 | container_name: caddy 89 | profiles: ["caddy"] 90 | build: ./frontend 91 | environment: 92 | NODE_ENV: development 93 | PORT: 3000 94 | BACKEND_URL: http://backend:3000 95 | LOG_LEVEL: info 96 | ports: ["3005:3000"] 97 | volumes: ["./frontend/Caddyfile:/etc/caddy/Caddyfile"] 98 | healthcheck: 99 | test: ["CMD", "curl", "-f", "http://localhost:3000"] 100 | depends_on: 101 | backend: 102 | condition: service_healthy 103 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Standard exclusions 2 | *.md 3 | .git 4 | .github 5 | .idea 6 | .vscode 7 | Dockerfile 8 | CODE_OF_CONDUCT* 9 | CONTRIBUTING* 10 | LICENSE* 11 | SECURITY* 12 | 13 | # Node exclusions 14 | dist 15 | node_modules 16 | 17 | # App-specific exclusions 18 | coverage 19 | cypress 20 | e2e 21 | migrations 22 | output 23 | test 24 | tests 25 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | .husky/ 2 | .vscode/ 3 | .yarn/ 4 | coverage/ 5 | dist/ 6 | public/assets/ 7 | tsconfig.*.json 8 | -------------------------------------------------------------------------------- /frontend/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | 6 | extends: 7 | - 'eslint:recommended' 8 | - 'plugin:react/recommended' 9 | - 'plugin:@typescript-eslint/recommended' 10 | - 'prettier' 11 | - 'plugin:prettier/recommended' 12 | 13 | parser: '@typescript-eslint/parser' 14 | 15 | parserOptions: 16 | ecmaFeatures: 17 | jsx: true 18 | ecmaVersion: 12 19 | sourceType: module 20 | 21 | plugins: 22 | - 'react' 23 | - 'react-hooks' 24 | - '@typescript-eslint' 25 | - 'prettier' 26 | - 'cypress' 27 | 28 | settings: 29 | react: 30 | version: 'detect' 31 | 32 | rules: 33 | # General ESLint rules 34 | 'no-console': 'off' 35 | 'no-debugger': 'warn' 36 | 'no-unused-vars': 'off' 37 | 'no-empty': ['error', { allowEmptyCatch: true }] 38 | 'no-undef': 'off' 39 | 'no-use-before-define': 'off' 40 | 'no-restricted-imports': 41 | [ 42 | 'error', 43 | { 44 | paths: 45 | [ 46 | { 47 | name: 'react', 48 | importNames: ['default'], 49 | message: "Please import from 'react/jsx-runtime' instead.", 50 | }, 51 | ], 52 | }, 53 | ] 54 | 55 | # React rules 56 | 'react/jsx-uses-react': 'off' 57 | 'react/react-in-jsx-scope': 'off' 58 | 'react/prop-types': 'off' 59 | 'react/display-name': 'off' 60 | 'react-hooks/rules-of-hooks': 'error' 61 | 'react-hooks/exhaustive-deps': 'warn' 62 | 63 | # TypeScript rules 64 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }] 65 | '@typescript-eslint/explicit-module-boundary-types': 'off' 66 | '@typescript-eslint/no-empty-interface': 'off' 67 | '@typescript-eslint/no-explicit-any': 'off' 68 | '@typescript-eslint/no-non-null-assertion': 'off' 69 | '@typescript-eslint/ban-types': 'off' 70 | '@typescript-eslint/no-use-before-define': ['error', { functions: false }] 71 | '@typescript-eslint/no-var-requires': 'off' 72 | '@typescript-eslint/explicit-function-return-type': 'off' 73 | '@typescript-eslint/consistent-type-imports': 74 | ['error', { prefer: 'type-imports' }] 75 | -------------------------------------------------------------------------------- /frontend/.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # .prettierrc.yml 2 | 3 | # Use single quotes instead of double quotes 4 | singleQuote: true 5 | 6 | # Use 2 spaces for indentation 7 | tabWidth: 2 8 | 9 | # Use spaces instead of tabs 10 | useTabs: false 11 | 12 | # Add a trailing comma to the last item in an object or array 13 | trailingComma: 'all' 14 | 15 | # Print semicolons at the ends of statements 16 | semi: false 17 | 18 | # Wrap prose-like comments as-is 19 | proseWrap: 'always' 20 | 21 | # Format files with Unix-style line endings 22 | endOfLine: 'lf' 23 | -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | metrics 3 | auto_https off 4 | admin 0.0.0.0:3003 5 | } 6 | :3000 { 7 | log { 8 | output stdout 9 | format console 10 | level {$LOG_LEVEL} 11 | } 12 | root * /srv 13 | encode zstd gzip 14 | file_server 15 | @spa_router { 16 | not path /api* 17 | file { 18 | try_files {path} /index.html 19 | } 20 | } 21 | rewrite @spa_router {http.matchers.file.relative} 22 | # Proxy requests to API service 23 | reverse_proxy /api* {$BACKEND_URL} { 24 | header_up Host {http.reverse_proxy.upstream.hostport} 25 | header_up X-Real-IP {remote_host} 26 | } 27 | header { 28 | -Server 29 | X-Frame-Options "SAMEORIGIN" 30 | X-XSS-Protection "1;mode=block" 31 | Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" 32 | X-Content-Type-Options "nosniff" 33 | Strict-Transport-Security "max-age=31536000" 34 | Content-Security-Policy "default-src 'self' https://*.gov.bc.ca;; 35 | script-src 'self' https://*.gov.bc.ca ; 36 | style-src 'self' https://fonts.googleapis.com https://use.fontawesome.com 'unsafe-inline'; 37 | font-src 'self' https://fonts.gstatic.com; 38 | img-src 'self' data: https://fonts.googleapis.com https://www.w3.org https://*.gov.bc.ca https://*.tile.openstreetmap.org; 39 | frame-ancestors 'self'; 40 | form-action 'self'; 41 | block-all-mixed-content; 42 | connect-src 'self' https://*.gov.bc.ca wss://*.gov.bc.ca;" 43 | Referrer-Policy "same-origin" 44 | Permissions-Policy "fullscreen=(self), camera=(), microphone=()" 45 | Cross-Origin-Resource-Policy "cross-origin" 46 | Cross-Origin-Opener-Policy "same-origin" 47 | } 48 | } 49 | :3001 { 50 | handle /health { 51 | respond "OK" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM node:22-slim AS build 3 | 4 | # Copy, build static files; see .dockerignore for exclusions 5 | WORKDIR /app 6 | COPY . ./ 7 | RUN npm run deploy 8 | 9 | # Deploy using Caddy to host static files 10 | FROM caddy:2.10.0-alpine 11 | RUN apk add --no-cache ca-certificates 12 | 13 | # Copy static files, verify Caddyfile formatting 14 | COPY --from=build /app/dist /srv 15 | COPY Caddyfile /etc/caddy/Caddyfile 16 | RUN caddy fmt /etc/caddy/Caddyfile 17 | 18 | # Boilerplate, not used in OpenShift/Kubernetes 19 | EXPOSE 3000 3001 20 | HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3001/health 21 | 22 | # Nonroot user 23 | USER 1001 24 | -------------------------------------------------------------------------------- /frontend/e2e/pages/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | import { baseURL } from '../utils' 3 | import type { Page } from 'playwright' 4 | 5 | export const dashboard_page = async (page: Page) => { 6 | await page.goto(baseURL) 7 | await expect( 8 | page.getByRole('link', { name: 'Government of British Columbia' }), 9 | ).toBeVisible() 10 | await expect(page.getByText('QuickStart OpenShift')).toBeVisible() 11 | await expect(page.getByText('Employee ID')).toBeVisible() 12 | await expect(page.getByText('Employee Name')).toBeVisible() 13 | await expect(page.getByText('Employee Email')).toBeVisible() 14 | await expect(page.getByRole('link', { name: 'Home' })).toBeVisible() 15 | await expect( 16 | page.getByRole('link', { name: 'About gov.bc.ca' }), 17 | ).toBeVisible() 18 | await expect(page.getByRole('link', { name: 'Disclaimer' })).toBeVisible() 19 | await expect(page.getByRole('link', { name: 'Privacy' })).toBeVisible() 20 | await expect(page.getByRole('link', { name: 'Accessibility' })).toBeVisible() 21 | await expect(page.getByRole('link', { name: 'Copyright' })).toBeVisible() 22 | await expect(page.getByRole('link', { name: 'Contact us' })).toBeVisible() 23 | await expect(page.getByText('John.ipsum@test.com')).toBeVisible() 24 | } 25 | -------------------------------------------------------------------------------- /frontend/e2e/qsos.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@playwright/test' 2 | import { dashboard_page } from './pages/dashboard' 3 | 4 | test.describe.parallel('QSOS', () => { 5 | test('Dashboard Page', async ({ page }) => { 6 | await dashboard_page(page) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /frontend/e2e/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const baseURL = 2 | process.env.E2E_BASE_URL || 3 | 'https://quickstart-openshift-test-frontend.apps.silver.devops.gov.bc.ca/' 4 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | QuickStart OpenShift 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quickstart-openshift-react-frontend", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "clean": "rimraf ./node_modules/.vite", 9 | "build:analyze": "vite build --mode analyze", 10 | "build:clean": "rimraf dist", 11 | "deploy": "npm ci --ignore-scripts --no-update-notifier --omit=dev && npm run build", 12 | "preview": "vite preview", 13 | "test:unit": "vitest --mode test", 14 | "test:cov": "vitest run --mode test --coverage", 15 | "build": "vite build" 16 | }, 17 | "dependencies": { 18 | "@bcgov/bc-sans": "^2.1.0", 19 | "@bcgov/design-system-react-components": "^0.5.0", 20 | "@popperjs/core": "^2.11.8", 21 | "@tanstack/react-router": "^1.114.12", 22 | "@tanstack/router-plugin": "^1.114.27", 23 | "@types/node": "^22.13.11", 24 | "@vitejs/plugin-react": "^4.3.4", 25 | "axios": "^1.7.7", 26 | "bootstrap": "^5.3.3", 27 | "bootstrap-icons": "^1.11.3", 28 | "react": "^19.0.0", 29 | "react-bootstrap": "^2.10.9", 30 | "react-dom": "^19.0.0", 31 | "sass-embedded": "^1.86.0", 32 | "vite": "^6.2.3", 33 | "vite-tsconfig-paths": "^5.1.4" 34 | }, 35 | "devDependencies": { 36 | "@faker-js/faker": "^9.0.0", 37 | "@playwright/test": "^1.51.0", 38 | "@tanstack/react-router-devtools": "^1.114.13", 39 | "@testing-library/jest-dom": "^6.0.0", 40 | "@testing-library/react": "^16.0.0", 41 | "@testing-library/user-event": "^14.4.3", 42 | "@types/react": "^19.0.10", 43 | "@types/react-dom": "^19.0.4", 44 | "@vitest/coverage-v8": "^3.0.8", 45 | "@vitest/ui": "^3.0.8", 46 | "eslint": "^9.2.2", 47 | "eslint-config-love": "^119.0.0", 48 | "eslint-config-prettier": "^10.1.1", 49 | "eslint-import-resolver-alias": "^1.1.2", 50 | "eslint-import-resolver-typescript": "^3.8.3", 51 | "eslint-plugin-cypress": "^4.2.0", 52 | "eslint-plugin-html": "^8.1.2", 53 | "eslint-plugin-import": "^2.31.0", 54 | "eslint-plugin-prettier": "^5.2.3", 55 | "eslint-plugin-promise": "^7.2.1", 56 | "eslint-plugin-react": "^7.37.4", 57 | "eslint-plugin-react-hooks": "^5.2.0", 58 | "eslint-plugin-tsdoc": "^0.4.0", 59 | "eslint-plugin-yaml": "^1.0.3", 60 | "history": "^5.3.0", 61 | "jsdom": "^26.0.0", 62 | "msw": "^2.7.3", 63 | "playwright": "^1.51.0", 64 | "prettier": "^3.5.3", 65 | "sass": "^1.85.1", 66 | "typescript": "^5.7.3", 67 | "vitest": "^3.0.8" 68 | }, 69 | "overrides": { 70 | "rollup@<4.22.4": "^4.22.4", 71 | "@types/react": "^19.0.10" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | import { baseURL } from './e2e/utils' 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | export default defineConfig({ 14 | timeout: 120000, 15 | testDir: './e2e', 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 2 : 0, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: [ 24 | ['line'], 25 | ['list', { printSteps: true }], 26 | ['html', { open: 'always' }], 27 | ], 28 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 29 | use: { 30 | /* Base URL to use in actions like `await page.goto('/')`. */ 31 | baseURL: baseURL, 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: 'on-first-retry', 35 | }, 36 | 37 | /* Configure projects for major browsers */ 38 | projects: [ 39 | { 40 | name: 'chromium', 41 | use: { 42 | ...devices['Desktop Chrome'], 43 | baseURL: baseURL, 44 | }, 45 | }, 46 | { 47 | name: 'Google Chrome', 48 | use: { 49 | ...devices['Desktop Chrome'], 50 | channel: 'chrome', 51 | baseURL: baseURL, 52 | }, 53 | }, 54 | 55 | { 56 | name: 'firefox', 57 | use: { 58 | ...devices['Desktop Firefox'], 59 | baseURL: baseURL, 60 | }, 61 | }, 62 | 63 | { 64 | name: 'safari', 65 | use: { 66 | ...devices['Desktop Safari'], 67 | baseURL: baseURL, 68 | }, 69 | }, 70 | { 71 | name: 'Microsoft Edge', 72 | use: { 73 | ...devices['Desktop Edge'], 74 | channel: 'msedge', 75 | baseURL: baseURL, 76 | }, 77 | }, 78 | ], 79 | }) 80 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/__tests__/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '../test-utils' 2 | import Dashboard from '@/components/Dashboard' 3 | 4 | describe('Simple working test', () => { 5 | it('the title is visible', () => { 6 | render() 7 | expect(screen.getByText(/QuickStart OpenShift/i)).toBeInTheDocument() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /frontend/src/assets/BCID_H_rgb_pos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/frontend/src/assets/BCID_H_rgb_pos.png -------------------------------------------------------------------------------- /frontend/src/assets/gov-bc-logo-horiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/frontend/src/assets/gov-bc-logo-horiz.png -------------------------------------------------------------------------------- /frontend/src/components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AxiosResponse } from '~/axios' 3 | import type UserDto from '@/interfaces/UserDto' 4 | import { useEffect, useState } from 'react' 5 | import { Table, Modal, Button } from 'react-bootstrap' 6 | import apiService from '@/service/api-service' 7 | 8 | type ModalProps = { 9 | show: boolean 10 | onHide: () => void 11 | user?: UserDto 12 | } 13 | 14 | const ModalComponent: FC = ({ show, onHide, user }) => { 15 | return ( 16 | 23 | 24 | 25 | Row Details 26 | 27 | 28 | {JSON.stringify(user)} 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | const Dashboard: FC = () => { 37 | const [data, setData] = useState([]) 38 | const [selectedUser, setSelectedUser] = useState( 39 | undefined, 40 | ) 41 | 42 | useEffect(() => { 43 | apiService 44 | .getAxiosInstance() 45 | .get('/v1/users') 46 | .then((response: AxiosResponse) => { 47 | const users: UserDto[] = [] 48 | for (const user of response.data) { 49 | const userDto = { 50 | id: user.id, 51 | name: user.name, 52 | email: user.email, 53 | } 54 | users.push(userDto) 55 | } 56 | setData(users) 57 | }) 58 | .catch((error) => { 59 | console.error(error) 60 | }) 61 | }, []) 62 | 63 | const handleClose = () => { 64 | setSelectedUser(undefined) 65 | } 66 | 67 | return ( 68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | 77 | 78 | 79 | {data.map((user: UserDto) => ( 80 | 81 | 82 | 83 | 84 | 93 | 94 | ))} 95 | 96 |
Employee IDEmployee NameEmployee Email 76 |
{user.id}{user.name}{user.email} 85 | 92 |
97 | 102 |
103 | ) 104 | } 105 | 106 | export default Dashboard 107 | -------------------------------------------------------------------------------- /frontend/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Footer, Header } from '@bcgov/design-system-react-components' 3 | import { Link } from '@tanstack/react-router' 4 | import { Button } from 'react-bootstrap' 5 | 6 | type Props = { 7 | children: React.ReactNode 8 | } 9 | 10 | const Layout: FC = ({ children }) => { 11 | return ( 12 |
13 |
14 | {' '} 15 | 16 | 19 | 20 |
21 |
22 | {children} 23 |
24 |
25 |
26 | ) 27 | } 28 | 29 | export default Layout 30 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Button } from 'react-bootstrap' 3 | import { useNavigate } from '@tanstack/react-router' 4 | 5 | const NotFound: FC = () => { 6 | const navigate = useNavigate() 7 | const buttonClicked = () => { 8 | navigate({ 9 | to: '/', 10 | }) 11 | } 12 | return ( 13 |
14 |

404

15 |
The page you’re looking for does not exist.
16 | 24 |
25 | ) 26 | } 27 | 28 | export default NotFound 29 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/Dashboard.test.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import Dashboard from '@/components/Dashboard' 4 | 5 | vi.mock('@tanstack/react-router', () => ({ 6 | useNavigate: vi.fn(), 7 | })) 8 | 9 | describe('Dashboard', () => { 10 | test('renders a heading with the correct text', () => { 11 | const navigate = vi.fn() 12 | const useNavigateMock = vi.fn(() => navigate) 13 | vi.doMock('@tanstack/react-router', () => ({ 14 | useNavigate: useNavigateMock, 15 | })) 16 | render() 17 | expect(screen.getByText(/Employee ID/i)).toBeInTheDocument() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/NotFound.test.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import NotFound from '@/components/NotFound' 4 | 5 | vi.mock('@tanstack/react-router', () => ({ 6 | useNavigate: vi.fn(), 7 | })) 8 | 9 | describe('NotFound', () => { 10 | test('renders a heading with the correct text', () => { 11 | const navigate = vi.fn() 12 | const useNavigateMock = vi.fn(() => navigate) 13 | vi.doMock('@tanstack/react-router', () => ({ 14 | useNavigate: useNavigateMock, 15 | })) 16 | render() 17 | const headingElement = screen.getByRole('heading', { name: /404/i }) 18 | expect(headingElement).toBeInTheDocument() 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | .MuiDrawer-paperAnchorLeft { 2 | margin-top: 4.2em !important; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/interfaces/UserDto.ts: -------------------------------------------------------------------------------- 1 | export default interface UserDto { 2 | id: number 3 | name: string 4 | email: string 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@bcgov/bc-sans/css/BC_Sans.css' 2 | import { StrictMode } from 'react' 3 | import * as ReactDOM from 'react-dom/client' 4 | import { RouterProvider, createRouter } from '@tanstack/react-router' 5 | 6 | // Import bootstrap styles 7 | import '@/scss/styles.scss' 8 | 9 | // Import the generated route tree 10 | import { routeTree } from './routeTree.gen' 11 | 12 | // Create a new router instance 13 | const router = createRouter({ routeTree }) 14 | 15 | // Register the router instance for type safety 16 | declare module '@tanstack/react-router' { 17 | interface Register { 18 | router: typeof router 19 | } 20 | } 21 | 22 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 23 | 24 | 25 | , 26 | ) 27 | -------------------------------------------------------------------------------- /frontend/src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from './routes/__root' 14 | import { Route as IndexImport } from './routes/index' 15 | 16 | // Create/Update Routes 17 | 18 | const IndexRoute = IndexImport.update({ 19 | id: '/', 20 | path: '/', 21 | getParentRoute: () => rootRoute, 22 | } as any) 23 | 24 | // Populate the FileRoutesByPath interface 25 | 26 | declare module '@tanstack/react-router' { 27 | interface FileRoutesByPath { 28 | '/': { 29 | id: '/' 30 | path: '/' 31 | fullPath: '/' 32 | preLoaderRoute: typeof IndexImport 33 | parentRoute: typeof rootRoute 34 | } 35 | } 36 | } 37 | 38 | // Create and export the route tree 39 | 40 | export interface FileRoutesByFullPath { 41 | '/': typeof IndexRoute 42 | } 43 | 44 | export interface FileRoutesByTo { 45 | '/': typeof IndexRoute 46 | } 47 | 48 | export interface FileRoutesById { 49 | __root__: typeof rootRoute 50 | '/': typeof IndexRoute 51 | } 52 | 53 | export interface FileRouteTypes { 54 | fileRoutesByFullPath: FileRoutesByFullPath 55 | fullPaths: '/' 56 | fileRoutesByTo: FileRoutesByTo 57 | to: '/' 58 | id: '__root__' | '/' 59 | fileRoutesById: FileRoutesById 60 | } 61 | 62 | export interface RootRouteChildren { 63 | IndexRoute: typeof IndexRoute 64 | } 65 | 66 | const rootRouteChildren: RootRouteChildren = { 67 | IndexRoute: IndexRoute, 68 | } 69 | 70 | export const routeTree = rootRoute 71 | ._addFileChildren(rootRouteChildren) 72 | ._addFileTypes() 73 | 74 | /* ROUTE_MANIFEST_START 75 | { 76 | "routes": { 77 | "__root__": { 78 | "filePath": "__root.tsx", 79 | "children": [ 80 | "/" 81 | ] 82 | }, 83 | "/": { 84 | "filePath": "index.tsx" 85 | } 86 | } 87 | } 88 | ROUTE_MANIFEST_END */ 89 | -------------------------------------------------------------------------------- /frontend/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, ErrorComponent, Outlet } from '@tanstack/react-router' 2 | import Layout from '@/components/Layout' 3 | import NotFound from '@/components/NotFound' 4 | 5 | export const Route = createRootRoute({ 6 | component: () => ( 7 | 8 | 9 | 10 | ), 11 | notFoundComponent: () => , 12 | errorComponent: ({ error }) => , 13 | }) 14 | -------------------------------------------------------------------------------- /frontend/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import Dashboard from '@/components/Dashboard' 3 | 4 | export const Route = createFileRoute('/')({ 5 | component: Index, 6 | }) 7 | 8 | function Index() { 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/scss/styles.scss: -------------------------------------------------------------------------------- 1 | // A custom theme for this app, see: https://getbootstrap.com/docs/5.3/customize/sass/#maps-and-loops 2 | $primary: #ffffff; 3 | $secondary: #385a8a; 4 | $success: #234720; 5 | $warning: #81692c; 6 | $danger: #712024; 7 | 8 | // Import Bootstrap and Bootstrap Icons 9 | @import '~bootstrap/scss/bootstrap'; 10 | $bootstrap-icons-font-src: 11 | url('../../node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2') 12 | format('woff2'), 13 | url('../../node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff') 14 | format('woff'); 15 | @import '../../node_modules/bootstrap-icons/font/bootstrap-icons.scss'; 16 | -------------------------------------------------------------------------------- /frontend/src/service/api-service.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosInstance } from 'axios' 2 | import axios from 'axios' 3 | 4 | class APIService { 5 | private readonly client: AxiosInstance 6 | 7 | constructor() { 8 | this.client = axios.create({ 9 | baseURL: '/api', 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | }) 14 | this.client.interceptors.response.use( 15 | (config) => { 16 | console.info( 17 | `received response status: ${config.status} , data: ${config.data}`, 18 | ) 19 | return config 20 | }, 21 | (error) => { 22 | console.error(error) 23 | }, 24 | ) 25 | } 26 | 27 | public getAxiosInstance(): AxiosInstance { 28 | return this.client 29 | } 30 | } 31 | 32 | export default new APIService() 33 | -------------------------------------------------------------------------------- /frontend/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { afterAll, afterEach, beforeAll } from 'vitest' 3 | import { setupServer } from 'msw/node' 4 | import { http, HttpResponse } from 'msw' 5 | 6 | const users = [ 7 | { 8 | id: 1, 9 | name: 'first post title', 10 | email: 'first post body', 11 | }, 12 | // ... 13 | ] 14 | 15 | export const restHandlers = [ 16 | http.get('http://localhost:3000/api/v1/users', () => { 17 | return new HttpResponse(JSON.stringify(users), { 18 | status: 200, 19 | }) 20 | }), 21 | ] 22 | 23 | const server = setupServer(...restHandlers) 24 | 25 | // Start server before all tests 26 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 27 | 28 | // Close server after all tests 29 | afterAll(() => server.close()) 30 | 31 | // Reset handlers after each test `important for test isolation` 32 | afterEach(() => server.resetHandlers()) 33 | -------------------------------------------------------------------------------- /frontend/src/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render } from '@testing-library/react' 2 | import { afterEach } from 'vitest' 3 | 4 | afterEach(() => { 5 | cleanup() 6 | }) 7 | 8 | function customRender(ui: React.ReactElement, options = {}) { 9 | return render(ui, { 10 | // wrap provider(s) here if needed 11 | wrapper: ({ children }) => children, 12 | ...options, 13 | }) 14 | } 15 | 16 | export * from '@testing-library/react' 17 | export { default as userEvent } from '@testing-library/user-event' 18 | // override render export 19 | export { customRender as render } 20 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "strictNullChecks": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "types": [ 20 | "vitest/globals", 21 | "node", 22 | ], 23 | "baseUrl": "./", 24 | "paths": { 25 | "@": ["src"], 26 | "test": ["test"], 27 | "test/*": ["test/*"], 28 | "@/*": ["src/*"], 29 | "~/*": ["node_modules/*"] 30 | } 31 | }, 32 | "include": ["src", "src/**/*", "src/**/*.tsx"], 33 | "references": [{ "path": "./tsconfig.node.json" }] 34 | } 35 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "types": ["node"], 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { fileURLToPath, URL } from 'node:url' 3 | import react from '@vitejs/plugin-react' 4 | import { TanStackRouterVite } from '@tanstack/router-plugin/vite' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | TanStackRouterVite({ 10 | target: 'react', 11 | autoCodeSplitting: true, 12 | }), 13 | react(), 14 | ], 15 | server: { 16 | port: parseInt(process.env.PORT), 17 | fs: { 18 | // Allow serving files from one level up to the project root 19 | allow: ['..'], 20 | }, 21 | proxy: { 22 | // Proxy API requests to the backend 23 | '/api': { 24 | target: 'http://localhost:3001', 25 | changeOrigin: true, 26 | }, 27 | }, 28 | }, 29 | resolve: { 30 | // https://vitejs.dev/config/shared-options.html#resolve-alias 31 | alias: { 32 | '@': fileURLToPath(new URL('./src', import.meta.url)), 33 | '~': fileURLToPath(new URL('./node_modules', import.meta.url)), 34 | '~bootstrap': fileURLToPath( 35 | new URL('./node_modules/bootstrap', import.meta.url), 36 | ), 37 | }, 38 | extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'], 39 | }, 40 | build: { 41 | // Build Target 42 | // https://vitejs.dev/config/build-options.html#build-target 43 | target: 'esnext', 44 | // Minify option 45 | // https://vitejs.dev/config/build-options.html#build-minify 46 | minify: 'esbuild', 47 | // Rollup Options 48 | // https://vitejs.dev/config/build-options.html#build-rollupoptions 49 | rollupOptions: { 50 | output: { 51 | manualChunks: { 52 | // Split external library from transpiled code. 53 | react: ['react', 'react-dom'], 54 | axios: ['axios'], 55 | }, 56 | }, 57 | }, 58 | }, 59 | css: { 60 | preprocessorOptions: { 61 | scss: { 62 | // Silence deprecation warnings caused by Bootstrap SCSS 63 | // which is out of our control. 64 | silenceDeprecations: [ 65 | 'mixed-decls', 66 | 'color-functions', 67 | 'global-builtin', 68 | 'import', 69 | ], 70 | }, 71 | }, 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /frontend/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import tsconfigPaths from 'vite-tsconfig-paths' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [tsconfigPaths()], 7 | test: { 8 | exclude: ['**/node_modules/**', '**/e2e/**'], 9 | globals: true, 10 | environment: 'jsdom', 11 | setupFiles: 'src/test-setup.ts', 12 | // you might want to disable it, if you don't have tests that rely on CSS 13 | // since parsing CSS is slow 14 | css: false, 15 | coverage: { 16 | reporter: ['lcov', 'text-summary', 'text', 'json', 'html'], 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /migrations/.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | coverage 4 | cypress 5 | dist 6 | node_modules 7 | -------------------------------------------------------------------------------- /migrations/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM flyway/flyway:11-alpine 2 | 3 | # Copy migrations 4 | COPY ./sql /flyway/sql 5 | 6 | # Non-root user 7 | RUN adduser -D app 8 | USER app 9 | 10 | # Health check and startup 11 | HEALTHCHECK CMD info 12 | # Adding repair to see if it fixes migration issues. 13 | CMD ["info", "migrate", "repair"] 14 | -------------------------------------------------------------------------------- /migrations/sql/V1.0.0__init.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA IF NOT EXISTS USERS; 2 | 3 | CREATE SEQUENCE IF NOT EXISTS USERS."USER_SEQ" 4 | START WITH 1 5 | INCREMENT BY 1 6 | NO MINVALUE 7 | NO MAXVALUE 8 | CACHE 100; 9 | 10 | CREATE TABLE IF NOT EXISTS USERS.USERS 11 | ( 12 | ID numeric not null 13 | constraint "USER_PK" 14 | primary key DEFAULT nextval('USERS."USER_SEQ"'), 15 | NAME varchar(200) not null, 16 | EMAIL varchar(200) not null 17 | ); 18 | INSERT INTO USERS.USERS (NAME, EMAIL) 19 | VALUES ('John', 'John.ipsum@test.com'), 20 | ('Jane', 'Jane.ipsum@test.com'), 21 | ('Jack', 'Jack.ipsum@test.com'), 22 | ('Jill', 'Jill.ipsum@test.com'), 23 | ('Joe', 'Joe.ipsum@test.com'); 24 | 25 | -------------------------------------------------------------------------------- /migrations/sql/V1.0.1__alter_user_seq.sql: -------------------------------------------------------------------------------- 1 | ALTER SEQUENCE USERS."USER_SEQ" RESTART WITH 6 CACHE 1; 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "description": "Presets from https://github.com/bcgov/renovate-config", 4 | "extends": [ 5 | "github>bcgov/renovate-config" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tests/integration/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integration-tests", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "integration-tests", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "axios": "^1.6.8", 12 | "dotenv": "^16.3.1", 13 | "js-yaml": "^4.1.0", 14 | "lodash": "^4.17.21" 15 | } 16 | }, 17 | "node_modules/argparse": { 18 | "version": "2.0.1", 19 | "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 20 | "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 21 | "license": "Python-2.0" 22 | }, 23 | "node_modules/asynckit": { 24 | "version": "0.4.0", 25 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 26 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 27 | "license": "MIT" 28 | }, 29 | "node_modules/axios": { 30 | "version": "1.9.0", 31 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", 32 | "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", 33 | "license": "MIT", 34 | "dependencies": { 35 | "follow-redirects": "^1.15.6", 36 | "form-data": "^4.0.0", 37 | "proxy-from-env": "^1.1.0" 38 | } 39 | }, 40 | "node_modules/call-bind-apply-helpers": { 41 | "version": "1.0.2", 42 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 43 | "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 44 | "license": "MIT", 45 | "dependencies": { 46 | "es-errors": "^1.3.0", 47 | "function-bind": "^1.1.2" 48 | }, 49 | "engines": { 50 | "node": ">= 0.4" 51 | } 52 | }, 53 | "node_modules/combined-stream": { 54 | "version": "1.0.8", 55 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 56 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 57 | "license": "MIT", 58 | "dependencies": { 59 | "delayed-stream": "~1.0.0" 60 | }, 61 | "engines": { 62 | "node": ">= 0.8" 63 | } 64 | }, 65 | "node_modules/delayed-stream": { 66 | "version": "1.0.0", 67 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 68 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 69 | "license": "MIT", 70 | "engines": { 71 | "node": ">=0.4.0" 72 | } 73 | }, 74 | "node_modules/dotenv": { 75 | "version": "16.5.0", 76 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 77 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 78 | "license": "BSD-2-Clause", 79 | "engines": { 80 | "node": ">=12" 81 | }, 82 | "funding": { 83 | "url": "https://dotenvx.com" 84 | } 85 | }, 86 | "node_modules/dunder-proto": { 87 | "version": "1.0.1", 88 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 89 | "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 90 | "license": "MIT", 91 | "dependencies": { 92 | "call-bind-apply-helpers": "^1.0.1", 93 | "es-errors": "^1.3.0", 94 | "gopd": "^1.2.0" 95 | }, 96 | "engines": { 97 | "node": ">= 0.4" 98 | } 99 | }, 100 | "node_modules/es-define-property": { 101 | "version": "1.0.1", 102 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 103 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 104 | "license": "MIT", 105 | "engines": { 106 | "node": ">= 0.4" 107 | } 108 | }, 109 | "node_modules/es-errors": { 110 | "version": "1.3.0", 111 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 112 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 113 | "license": "MIT", 114 | "engines": { 115 | "node": ">= 0.4" 116 | } 117 | }, 118 | "node_modules/es-object-atoms": { 119 | "version": "1.1.1", 120 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 121 | "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 122 | "license": "MIT", 123 | "dependencies": { 124 | "es-errors": "^1.3.0" 125 | }, 126 | "engines": { 127 | "node": ">= 0.4" 128 | } 129 | }, 130 | "node_modules/es-set-tostringtag": { 131 | "version": "2.1.0", 132 | "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", 133 | "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 134 | "license": "MIT", 135 | "dependencies": { 136 | "es-errors": "^1.3.0", 137 | "get-intrinsic": "^1.2.6", 138 | "has-tostringtag": "^1.0.2", 139 | "hasown": "^2.0.2" 140 | }, 141 | "engines": { 142 | "node": ">= 0.4" 143 | } 144 | }, 145 | "node_modules/follow-redirects": { 146 | "version": "1.15.9", 147 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 148 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 149 | "funding": [ 150 | { 151 | "type": "individual", 152 | "url": "https://github.com/sponsors/RubenVerborgh" 153 | } 154 | ], 155 | "license": "MIT", 156 | "engines": { 157 | "node": ">=4.0" 158 | }, 159 | "peerDependenciesMeta": { 160 | "debug": { 161 | "optional": true 162 | } 163 | } 164 | }, 165 | "node_modules/form-data": { 166 | "version": "4.0.3", 167 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", 168 | "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", 169 | "license": "MIT", 170 | "dependencies": { 171 | "asynckit": "^0.4.0", 172 | "combined-stream": "^1.0.8", 173 | "es-set-tostringtag": "^2.1.0", 174 | "hasown": "^2.0.2", 175 | "mime-types": "^2.1.12" 176 | }, 177 | "engines": { 178 | "node": ">= 6" 179 | } 180 | }, 181 | "node_modules/function-bind": { 182 | "version": "1.1.2", 183 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 184 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 185 | "license": "MIT", 186 | "funding": { 187 | "url": "https://github.com/sponsors/ljharb" 188 | } 189 | }, 190 | "node_modules/get-intrinsic": { 191 | "version": "1.3.0", 192 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 193 | "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 194 | "license": "MIT", 195 | "dependencies": { 196 | "call-bind-apply-helpers": "^1.0.2", 197 | "es-define-property": "^1.0.1", 198 | "es-errors": "^1.3.0", 199 | "es-object-atoms": "^1.1.1", 200 | "function-bind": "^1.1.2", 201 | "get-proto": "^1.0.1", 202 | "gopd": "^1.2.0", 203 | "has-symbols": "^1.1.0", 204 | "hasown": "^2.0.2", 205 | "math-intrinsics": "^1.1.0" 206 | }, 207 | "engines": { 208 | "node": ">= 0.4" 209 | }, 210 | "funding": { 211 | "url": "https://github.com/sponsors/ljharb" 212 | } 213 | }, 214 | "node_modules/get-proto": { 215 | "version": "1.0.1", 216 | "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 217 | "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 218 | "license": "MIT", 219 | "dependencies": { 220 | "dunder-proto": "^1.0.1", 221 | "es-object-atoms": "^1.0.0" 222 | }, 223 | "engines": { 224 | "node": ">= 0.4" 225 | } 226 | }, 227 | "node_modules/gopd": { 228 | "version": "1.2.0", 229 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 230 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 231 | "license": "MIT", 232 | "engines": { 233 | "node": ">= 0.4" 234 | }, 235 | "funding": { 236 | "url": "https://github.com/sponsors/ljharb" 237 | } 238 | }, 239 | "node_modules/has-symbols": { 240 | "version": "1.1.0", 241 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 242 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 243 | "license": "MIT", 244 | "engines": { 245 | "node": ">= 0.4" 246 | }, 247 | "funding": { 248 | "url": "https://github.com/sponsors/ljharb" 249 | } 250 | }, 251 | "node_modules/has-tostringtag": { 252 | "version": "1.0.2", 253 | "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", 254 | "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 255 | "license": "MIT", 256 | "dependencies": { 257 | "has-symbols": "^1.0.3" 258 | }, 259 | "engines": { 260 | "node": ">= 0.4" 261 | }, 262 | "funding": { 263 | "url": "https://github.com/sponsors/ljharb" 264 | } 265 | }, 266 | "node_modules/hasown": { 267 | "version": "2.0.2", 268 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 269 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 270 | "license": "MIT", 271 | "dependencies": { 272 | "function-bind": "^1.1.2" 273 | }, 274 | "engines": { 275 | "node": ">= 0.4" 276 | } 277 | }, 278 | "node_modules/js-yaml": { 279 | "version": "4.1.0", 280 | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", 281 | "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", 282 | "license": "MIT", 283 | "dependencies": { 284 | "argparse": "^2.0.1" 285 | }, 286 | "bin": { 287 | "js-yaml": "bin/js-yaml.js" 288 | } 289 | }, 290 | "node_modules/lodash": { 291 | "version": "4.17.21", 292 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 293 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", 294 | "license": "MIT" 295 | }, 296 | "node_modules/math-intrinsics": { 297 | "version": "1.1.0", 298 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 299 | "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 300 | "license": "MIT", 301 | "engines": { 302 | "node": ">= 0.4" 303 | } 304 | }, 305 | "node_modules/mime-db": { 306 | "version": "1.52.0", 307 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 308 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 309 | "license": "MIT", 310 | "engines": { 311 | "node": ">= 0.6" 312 | } 313 | }, 314 | "node_modules/mime-types": { 315 | "version": "2.1.35", 316 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 317 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 318 | "license": "MIT", 319 | "dependencies": { 320 | "mime-db": "1.52.0" 321 | }, 322 | "engines": { 323 | "node": ">= 0.6" 324 | } 325 | }, 326 | "node_modules/proxy-from-env": { 327 | "version": "1.1.0", 328 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 329 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 330 | "license": "MIT" 331 | } 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /tests/integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integration-tests", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/main.js", 6 | "scripts": { 7 | "dev": "node src/main.js" 8 | }, 9 | "dependencies": { 10 | "axios": "^1.6.8", 11 | "dotenv": "^16.3.1", 12 | "js-yaml": "^4.1.0", 13 | "lodash": "^4.17.21" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/integration/src/main.js: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import axios from "axios"; 3 | import * as fs from "fs"; 4 | import * as path from "path"; 5 | import { fileURLToPath } from "url"; 6 | import assert from "node:assert/strict"; 7 | 8 | import pkg from "lodash"; 9 | 10 | dotenv.config(); 11 | 12 | const { isEqual, omit } = pkg; 13 | 14 | const __filename = fileURLToPath(import.meta.url); 15 | 16 | const __dirname = path.dirname(__filename); 17 | const apiName = process.env.API_NAME; 18 | const BASE_URL = process.env.BASE_URL; 19 | 20 | async function performEachMethod(BASE_URL, testCase, method, id) { 21 | let url = BASE_URL + testCase.path; 22 | if (id && (method === "GET" || method === "PUT" || method === "PATCH" || method === "DELETE")) { 23 | if (url.endsWith("/") === false) { 24 | url = url + "/" + id; 25 | } else { 26 | url = url + id; 27 | } 28 | } 29 | let payload; 30 | if (method === "POST") { 31 | payload = testCase.data?.post_payload; 32 | } else if (method === "PUT") { 33 | payload = testCase.data?.put_payload; 34 | } else if (method === "PATCH") { 35 | payload = testCase.data?.patch_payload; 36 | } 37 | const response = await axios({ 38 | method: method, 39 | url: url, 40 | headers: { 41 | ...testCase.headers 42 | }, 43 | data: payload 44 | }); 45 | console.info(`Response for ${method} ${url} : ${response.status}`); 46 | const methodAssertion = testCase.assertions.find(assertion => assertion.method === method); 47 | const responseData = response.data?.data || response.data; 48 | if (methodAssertion) { 49 | if (methodAssertion.status_code) { 50 | assert(response.status === methodAssertion.status_code); 51 | } 52 | if (methodAssertion.body) { 53 | assert(isEqual(omit(responseData, testCase.data.id_field), methodAssertion.body) === true); 54 | } 55 | } 56 | if (method === "POST") { 57 | return responseData[testCase.data.id_field]; 58 | } 59 | } 60 | 61 | async function performTesting(testSuitesDir, testSuiteFile) { 62 | console.info(`Running test suite for : ${testSuiteFile}`); 63 | const testSuitePath = path.join(testSuitesDir, testSuiteFile); 64 | const testSuite = JSON.parse(await fs.promises.readFile(testSuitePath, "utf-8")); 65 | for (const testCase of testSuite.tests) { 66 | let id = null; 67 | for (const method of testCase.methods) { 68 | const responseId = await performEachMethod(BASE_URL, testCase, method, id); 69 | if (responseId) { 70 | id = responseId; 71 | } 72 | } 73 | } 74 | } 75 | 76 | const main = async () => { 77 | const testSuitesDir = path.join(__dirname, "test_suites"); 78 | const testSuiteFiles = await fs.promises.readdir(testSuitesDir); 79 | const testFile = testSuiteFiles.find(file => file.includes(apiName)); 80 | await performTesting(testSuitesDir, testFile); 81 | }; 82 | 83 | try { 84 | await main(); 85 | } catch (e) { 86 | if (e instanceof assert.AssertionError) { 87 | console.error(e); 88 | process.exit(137); 89 | } 90 | console.error(e); 91 | process.exit(137); 92 | } 93 | 94 | 95 | -------------------------------------------------------------------------------- /tests/integration/src/test_suites/it.backend.fastapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_name": "fastapi", 3 | "api_version": "1.0.0", 4 | "base_url": "${BASE_URL}", 5 | "tests": [ 6 | { 7 | "name": "User Endpoint TEST", 8 | "methods": [ 9 | "GET", 10 | "POST", 11 | "GET", 12 | "DELETE" 13 | ], 14 | "path": "/api/v1/user/", 15 | "headers": { 16 | "Content-Type": "application/json", 17 | "accept": "application/json" 18 | }, 19 | "data": { 20 | "id_field": "user_id", 21 | "post_payload": { 22 | "name": "John", 23 | "email": "John.ipsum@test.com" 24 | }, 25 | "put_payload": { 26 | "name": "Jane", 27 | "email": "Jane.ipsum@test.com" 28 | }, 29 | "patch_payload": { 30 | "name": "Jane", 31 | "email": "Jane.ipsum@test.com" 32 | } 33 | }, 34 | "assertions": [ 35 | { 36 | "method": "POST", 37 | "status_code": 200, 38 | "body": { 39 | "name": "John", 40 | "email": "John.ipsum@test.com" 41 | } 42 | }, 43 | { 44 | "method": "GET", 45 | "status_code": 200 46 | }, 47 | { 48 | "method": "PUT", 49 | "status_code": 200, 50 | "body": { 51 | "name": "Jane", 52 | "email": "Jane.ipsum@test.com" 53 | } 54 | }, 55 | { 56 | "method": "DELETE", 57 | "status_code": 200 58 | } 59 | ] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /tests/integration/src/test_suites/it.backend.fiber.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_name": "fiber", 3 | "api_version": "1.0.0", 4 | "base_url": "${BASE_URL}", 5 | "tests": [ 6 | { 7 | "name": "User Endpoint TEST", 8 | "methods": [ 9 | "GET", 10 | "POST", 11 | "PUT", 12 | "GET", 13 | "DELETE" 14 | ], 15 | "path": "/api/v1/users", 16 | "headers": { 17 | "Content-Type": "application/json" 18 | }, 19 | "data": { 20 | "id_field": "id", 21 | "post_payload": { 22 | "name": "John", 23 | "email": "John.ipsum@test.com", 24 | "addresses": [ 25 | ] 26 | }, 27 | "put_payload": { 28 | "name": "Jane", 29 | "email": "Jane.ipsum@test.com", 30 | "addresses": [ 31 | ] 32 | }, 33 | "patch_payload": { 34 | "name": "Jane", 35 | "email": "Jane.ipsum@test.com" 36 | } 37 | }, 38 | "assertions": [ 39 | { 40 | "method": "POST", 41 | "status_code": 201, 42 | "body": { 43 | "name": "John", 44 | "email": "John.ipsum@test.com", 45 | "addresses": null 46 | } 47 | }, 48 | { 49 | "method": "GET", 50 | "status_code": 200 51 | }, 52 | { 53 | "method": "PUT", 54 | "status_code": 200, 55 | "body": { 56 | "name": "Jane", 57 | "email": "Jane.ipsum@test.com", 58 | "addresses": null 59 | } 60 | }, 61 | { 62 | "method": "DELETE", 63 | "status_code": 204 64 | } 65 | ] 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /tests/integration/src/test_suites/it.backend.nest.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_name": "nest", 3 | "api_version": "1.0.0", 4 | "base_url": "${BASE_URL}", 5 | "tests": [ 6 | { 7 | "name": "User Endpoint TEST", 8 | "methods": [ 9 | "GET", 10 | "POST", 11 | "PUT", 12 | "GET", 13 | "DELETE" 14 | ], 15 | "path": "/api/v1/users", 16 | "headers": { 17 | "Content-Type": "application/json" 18 | }, 19 | "data": { 20 | "id_field": "id", 21 | "post_payload": { 22 | "name": "John", 23 | "email": "John.ipsum@test.com" 24 | }, 25 | "put_payload": { 26 | "name": "Jane", 27 | "email": "Jane.ipsum@test.com" 28 | }, 29 | "patch_payload": { 30 | "name": "Jane", 31 | "email": "Jane.ipsum@test.com" 32 | } 33 | }, 34 | "assertions": [ 35 | { 36 | "method": "POST", 37 | "status_code": 201, 38 | "body": { 39 | "name": "John", 40 | "email": "John.ipsum@test.com" 41 | } 42 | }, 43 | { 44 | "method": "GET", 45 | "status_code": 200 46 | }, 47 | { 48 | "method": "PUT", 49 | "status_code": 200, 50 | "body": { 51 | "name": "Jane", 52 | "email": "Jane.ipsum@test.com" 53 | } 54 | }, 55 | { 56 | "method": "DELETE", 57 | "status_code": 200 58 | } 59 | ] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /tests/integration/src/test_suites/it.backend.quarkus.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_name": "nest", 3 | "api_version": "1.0.0", 4 | "base_url": "${BASE_URL}", 5 | "tests": [ 6 | { 7 | "name": "User Endpoint TEST", 8 | "methods": [ 9 | "GET", 10 | "POST", 11 | "PUT", 12 | "GET", 13 | "DELETE" 14 | ], 15 | "path": "/api/v1/users", 16 | "headers": { 17 | "Content-Type": "application/json" 18 | }, 19 | "data": { 20 | "id_field": "id", 21 | "post_payload": { 22 | "name": "John", 23 | "email": "John.ipsum@test.com" 24 | }, 25 | "put_payload": { 26 | "name": "Jane", 27 | "email": "Jane.ipsum@test.com" 28 | }, 29 | "patch_payload": { 30 | "name": "Jane", 31 | "email": "Jane.ipsum@test.com" 32 | } 33 | }, 34 | "assertions": [ 35 | { 36 | "method": "POST", 37 | "status_code": 201, 38 | "body": { 39 | "name": "John", 40 | "email": "John.ipsum@test.com" 41 | } 42 | }, 43 | { 44 | "method": "GET", 45 | "status_code": 200 46 | }, 47 | { 48 | "method": "PUT", 49 | "status_code": 200, 50 | "body": { 51 | "name": "Jane", 52 | "email": "Jane.ipsum@test.com" 53 | } 54 | }, 55 | { 56 | "method": "DELETE", 57 | "status_code": 204 58 | } 59 | ] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /tests/load/README.md: -------------------------------------------------------------------------------- 1 | # This Directory contains the load test scripts for the project. 2 | ## The scripts are written in JS and use the k6 framework. [k6](https://k6.io) 3 | 4 | 1. The Tests are samples, dev teams are encouraged to write their own tests based on the use cases of their project. 5 | 2. The tests are run using GitHub Actions. 6 | 7 | # Please inform platform services team if you would do a HUGE load TEST on OpenShift platform as it impacts the platform performance. 8 | ``` 9 | -------------------------------------------------------------------------------- /tests/load/backend-test.js: -------------------------------------------------------------------------------- 1 | import { check } from "k6"; 2 | import http from "k6/http"; 3 | import { Rate } from "k6/metrics"; 4 | 5 | 6 | export let errorRate = new Rate("errors"); 7 | 8 | 9 | function checkStatus(response, checkName, statusCode = 200) { 10 | let success = check(response, { 11 | [checkName]: (r) => { 12 | if (r.status === statusCode) { 13 | return true; 14 | } else { 15 | console.error(checkName + " failed. Incorrect response code." + r.status); 16 | return false; 17 | } 18 | } 19 | }); 20 | errorRate.add(!success, { tag1: checkName }); 21 | } 22 | 23 | 24 | export default function() { 25 | let url = `${__ENV.BACKEND_URL}/v1/users`; 26 | let params = { 27 | headers: { 28 | "Content-Type": "application/json" 29 | } 30 | }; 31 | let res = http.get(url, params); 32 | checkStatus(res, "get-all-users", 200); 33 | 34 | } 35 | -------------------------------------------------------------------------------- /tests/load/frontend-test.js: -------------------------------------------------------------------------------- 1 | import { check } from "k6"; 2 | import http from "k6/http"; 3 | import { Rate } from "k6/metrics"; 4 | 5 | 6 | export let errorRate = new Rate("errors"); 7 | 8 | 9 | function checkStatus(response, checkName, statusCode = 200) { 10 | let success = check(response, { 11 | [checkName]: (r) => { 12 | if (r.status === statusCode) { 13 | return true; 14 | } else { 15 | console.error(checkName + " failed. Incorrect response code." + r.status); 16 | return false; 17 | } 18 | } 19 | }); 20 | errorRate.add(!success, { tag1: checkName }); 21 | } 22 | 23 | 24 | export default function(token) { 25 | let url = `${__ENV.FRONTEND_URL}`; 26 | 27 | let res = http.get(url); 28 | checkStatus(res, "frontend", 200); 29 | 30 | } 31 | -------------------------------------------------------------------------------- /transfer/.github/workflows/project-sync.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/transfer/.github/workflows/project-sync.yml -------------------------------------------------------------------------------- /transfer/repos.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/transfer/repos.yaml -------------------------------------------------------------------------------- /transfer/scripts/sync-to-project.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/a8b52a3f545035db4bb10020c869dfc7e568c007/transfer/scripts/sync-to-project.js --------------------------------------------------------------------------------