├── frontend ├── src │ ├── index.css │ ├── assets │ │ ├── BCID_H_rgb_pos.png │ │ └── gov-bc-logo-horiz.png │ ├── interfaces │ │ └── UserDto.ts │ ├── routes │ │ ├── index.tsx │ │ └── __root.tsx │ ├── __tests__ │ │ └── Dashboard.tsx │ ├── test-utils.tsx │ ├── scss │ │ └── styles.scss │ ├── components │ │ ├── __tests__ │ │ │ ├── Dashboard.test.tsx │ │ │ └── NotFound.test.tsx │ │ ├── NotFound.tsx │ │ ├── Layout.tsx │ │ └── Dashboard.tsx │ ├── service │ │ └── api-service.ts │ ├── main.tsx │ ├── test-setup.ts │ └── routeTree.gen.ts ├── public │ └── favicon.ico ├── .vscode │ └── extensions.json ├── e2e │ ├── utils │ │ └── index.ts │ ├── qsos.spec.ts │ └── pages │ │ └── dashboard.ts ├── tsconfig.node.json ├── .dockerignore ├── index.html ├── tsconfig.json ├── vitest.config.ts ├── Dockerfile ├── Caddyfile ├── playwright.config.ts ├── vite.config.ts ├── package.json ├── eslint.config.mjs └── coraza.conf ├── migrations ├── .dockerignore ├── sql │ ├── V1.0.1__alter_user_seq.sql │ └── V1.0.0__init.sql └── Dockerfile ├── .github ├── graphics │ ├── merge.png │ ├── pr-open.png │ ├── analysis.png │ ├── packages.png │ ├── pr-close.png │ ├── scheduled.png │ ├── schemaspy.png │ ├── template.png │ ├── demo-label.png │ ├── pr-cleanup.png │ ├── pr-validate.png │ ├── demo-workflow.png │ ├── branch-protection.png │ ├── deploymentUpdate.png │ ├── mergeNotification.png │ └── branch-code-results.png ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── decision.md │ ├── task.md │ ├── epic.md │ ├── question.md │ ├── ux.md │ ├── bug.md │ ├── documentation.md │ └── feature.md ├── codeowners ├── workflows │ ├── pr-validate.yml │ ├── pr-close.yml │ ├── demo.yml │ ├── pr-open.yml │ ├── merge.yml │ ├── .tests.yml │ ├── scheduled.yml │ ├── analysis.yml │ └── .deployer.yml └── pull_request_template.md ├── backend ├── src │ ├── users │ │ ├── dto │ │ │ ├── update-user.dto.ts │ │ │ ├── create-user.dto.ts │ │ │ └── user.dto.ts │ │ ├── users.module.ts │ │ ├── users.controller.ts │ │ ├── users.service.ts │ │ ├── users.controller.spec.ts │ │ └── users.service.spec.ts │ ├── app.service.ts │ ├── prisma.module.ts │ ├── app.controller.ts │ ├── metrics.controller.ts │ ├── middleware │ │ ├── prom.ts │ │ ├── req.res.logger.ts │ │ └── req.res.logger.spec.ts │ ├── common │ │ ├── logger.config.spec.ts │ │ └── logger.config.ts │ ├── main.ts │ ├── prisma.service.spec.ts │ ├── health.controller.ts │ ├── app.controller.spec.ts │ ├── app.spec.ts │ ├── app.module.ts │ ├── app.ts │ ├── metrics.controller.spec.ts │ └── prisma.service.ts ├── tsconfig.build.json ├── .dockerignore ├── nest-cli.json ├── test │ └── app.e2e-spec.ts ├── prisma │ └── schema.prisma ├── tsconfig.json ├── prisma.config.ts ├── vitest.config.mts ├── Dockerfile ├── eslint.config.mjs └── package.json ├── renovate.json ├── charts ├── app │ ├── Chart.lock │ ├── .helmignore │ ├── README.md.gotmpl │ ├── templates │ │ ├── backend │ │ │ └── templates │ │ │ │ ├── pdb.yaml │ │ │ │ ├── service.yaml │ │ │ │ ├── _helpers.tpl │ │ │ │ ├── hpa.yaml │ │ │ │ └── deployment.yaml │ │ ├── frontend │ │ │ └── templates │ │ │ │ ├── pdb.yaml │ │ │ │ ├── service.yaml │ │ │ │ ├── ingress.yaml │ │ │ │ ├── _helpers.tpl │ │ │ │ ├── hpa.yaml │ │ │ │ └── deployment.yaml │ │ ├── _helpers.tpl │ │ ├── knp.yaml │ │ └── secret.yaml │ ├── Chart.yaml │ └── values.yaml └── crunchy │ └── values.yml ├── .vscode ├── extensions.json └── settings.json ├── SECURITY.md ├── COMPLIANCE.yaml ├── tests ├── integration │ ├── package.json │ └── src │ │ ├── test_suites │ │ ├── it.backend.nest.json │ │ ├── it.backend.quarkus.json │ │ ├── it.backend.fastapi.json │ │ └── it.backend.fiber.json │ │ └── main.js └── load │ ├── README.md │ ├── frontend-test.js │ └── backend-test.js ├── .editorconfig ├── .prettierrc.yml ├── CONTRIBUTING.md ├── eslint-base.config.mjs ├── docker-compose.yml ├── CODE_OF_CONDUCT.md ├── .gitignore └── LICENSE /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | .MuiDrawer-paperAnchorLeft { 2 | margin-top: 4.2em !important; 3 | } 4 | -------------------------------------------------------------------------------- /migrations/.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | coverage 4 | cypress 5 | dist 6 | node_modules 7 | -------------------------------------------------------------------------------- /migrations/sql/V1.0.1__alter_user_seq.sql: -------------------------------------------------------------------------------- 1 | ALTER SEQUENCE USERS."USER_SEQ" RESTART WITH 6 CACHE 1; 2 | -------------------------------------------------------------------------------- /.github/graphics/merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/merge.png -------------------------------------------------------------------------------- /.github/graphics/pr-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/pr-open.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /.github/graphics/analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/analysis.png -------------------------------------------------------------------------------- /.github/graphics/packages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/packages.png -------------------------------------------------------------------------------- /.github/graphics/pr-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/pr-close.png -------------------------------------------------------------------------------- /.github/graphics/scheduled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/scheduled.png -------------------------------------------------------------------------------- /.github/graphics/schemaspy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/schemaspy.png -------------------------------------------------------------------------------- /.github/graphics/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/template.png -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/graphics/demo-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/demo-label.png -------------------------------------------------------------------------------- /.github/graphics/pr-cleanup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/pr-cleanup.png -------------------------------------------------------------------------------- /.github/graphics/pr-validate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/pr-validate.png -------------------------------------------------------------------------------- /.github/graphics/demo-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/demo-workflow.png -------------------------------------------------------------------------------- /.github/graphics/branch-protection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/branch-protection.png -------------------------------------------------------------------------------- /.github/graphics/deploymentUpdate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/deploymentUpdate.png -------------------------------------------------------------------------------- /.github/graphics/mergeNotification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/mergeNotification.png -------------------------------------------------------------------------------- /frontend/src/assets/BCID_H_rgb_pos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/frontend/src/assets/BCID_H_rgb_pos.png -------------------------------------------------------------------------------- /frontend/src/interfaces/UserDto.ts: -------------------------------------------------------------------------------- 1 | export default interface UserDto { 2 | id: number 3 | name: string 4 | email: string 5 | } 6 | -------------------------------------------------------------------------------- /.github/graphics/branch-code-results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/.github/graphics/branch-code-results.png -------------------------------------------------------------------------------- /frontend/src/assets/gov-bc-logo-horiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcgov/quickstart-openshift/HEAD/frontend/src/assets/gov-bc-logo-horiz.png -------------------------------------------------------------------------------- /backend/src/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserDto } from './create-user.dto' 2 | 3 | export class UpdateUserDto extends CreateUserDto {} 4 | -------------------------------------------------------------------------------- /frontend/e2e/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const baseURL = 2 | process.env.E2E_BASE_URL || 'https://quickstart-openshift-test.apps.silver.devops.gov.bc.ca/' 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"], 4 | "include": ["src/**/*.ts", "generated/**/*.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /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/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, ['email', 'name'] as const) {} 5 | -------------------------------------------------------------------------------- /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 {} 9 | -------------------------------------------------------------------------------- /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#2025.10.1" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", // Required for format on save 4 | "dbaeumer.vscode-eslint", // Required for ESLint auto-fix 5 | "editorconfig.editorconfig" // Reads .editorconfig file 6 | ] 7 | } 8 | 9 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "builder": "swc", 6 | "typeCheck": true, 7 | "assets": [ 8 | { 9 | "include": "../generated/prisma/**/*", 10 | "outDir": "dist" 11 | } 12 | ], 13 | "watchAssets": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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": "^17.0.0", 12 | "js-yaml": "^4.1.0", 13 | "lodash": "^4.17.21" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.{js,jsx,ts,tsx,json,yml,yaml}] 14 | indent_size = 2 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/src/metrics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Res } from '@nestjs/common' 2 | import type { Response } from 'express' 3 | import { register } from 'src/middleware/prom' 4 | 5 | @Controller('metrics') 6 | export class MetricsController { 7 | @Get() 8 | async getMetrics(@Res() res: Response) { 9 | const appMetrics = await register.metrics() 10 | res.end(appMetrics) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Minimal settings to prevent conflicts - uses VS Code defaults where possible 3 | 4 | // Enable format on save (applies to all languages by default) 5 | "editor.formatOnSave": true, 6 | 7 | // Auto-fix ESLint issues on save 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "explicit" 10 | }, 11 | 12 | // Only critical setting: tell ESLint to use flat config 13 | "eslint.useFlatConfig": true 14 | } 15 | 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/main.ts: -------------------------------------------------------------------------------- 1 | import type { NestExpressApplication } from '@nestjs/platform-express' 2 | import { bootstrap } from './app' 3 | import { Logger } from '@nestjs/common' 4 | const logger = new Logger('NestApplication') 5 | bootstrap() 6 | .then(async (app: NestExpressApplication) => { 7 | await app.listen(3000) 8 | logger.log(`Listening on ${await app.getUrl()}`) 9 | logger.log(`Process start up took ${process.uptime()} seconds`) 10 | }) 11 | .catch((err) => { 12 | logger.error(err) 13 | }) 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | QuickStart OpenShift 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { Test } from '@nestjs/testing' 3 | import type { 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)', () => request(app.getHttpServer()).get('/').expect(200).expect('Hello Backend!')) 19 | }) 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /backend/src/prisma.service.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing' 2 | import { Test } from '@nestjs/testing' 3 | import { PrismaService } from './prisma.service' 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client" 3 | output = "../generated/prisma" 4 | moduleFormat = "cjs" 5 | binaryTargets = ["native", "debian-openssl-3.0.x"] 6 | } 7 | 8 | // NOTE: The datasource URL is now configured in prisma.config.ts (Prisma 7+). 9 | // This file no longer contains the `url` field for the datasource. 10 | datasource db { 11 | provider = "postgresql" 12 | schemas = ["users"] 13 | } 14 | 15 | model users { 16 | id Decimal @id(map: "USER_PK") @default(dbgenerated("nextval('\"USER_SEQ\"'::regclass)")) @db.Decimal 17 | name String @db.VarChar(200) 18 | email String @db.VarChar(200) 19 | 20 | @@schema("users") 21 | } 22 | -------------------------------------------------------------------------------- /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 | 19 |
20 | ) 21 | } 22 | 23 | export default NotFound 24 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/src/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common' 2 | import { HealthCheck, HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus' 3 | import { PrismaService } from 'src/prisma.service' 4 | import type { PrismaClient } from '../generated/prisma/client.js' 5 | @Controller('health') 6 | export class HealthController { 7 | constructor( 8 | private health: HealthCheckService, 9 | private prisma: PrismaHealthIndicator, 10 | private readonly prismaService: PrismaService, 11 | ) {} 12 | 13 | @Get() 14 | @HealthCheck() 15 | check() { 16 | return this.health.check([ 17 | () => this.prisma.pingCheck('prisma', this.prismaService as unknown as PrismaClient), 18 | ]) 19 | } 20 | } 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing' 2 | import { Test } from '@nestjs/testing' 3 | import { AppController } from './app.controller' 4 | import { AppService } from './app.service' 5 | 6 | describe('AppController', () => { 7 | let appController: AppController 8 | 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | controllers: [AppController], 12 | providers: [AppService], 13 | }).compile() 14 | 15 | appController = app.get(AppController) 16 | }) 17 | 18 | describe('root', () => { 19 | it('should return "Hello Backend!"', () => { 20 | expect(appController.getHello()).toBe('Hello Backend!') 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # Shared Prettier configuration for entire repository 2 | # This ensures consistent formatting between frontend, backend, and IDE 3 | 4 | # Use single quotes instead of double quotes 5 | singleQuote: true 6 | 7 | # Use 2 spaces for indentation 8 | tabWidth: 2 9 | 10 | # Use spaces instead of tabs 11 | useTabs: false 12 | 13 | # Add a trailing comma to the last item in an object or array 14 | trailingComma: 'all' 15 | 16 | # Print semicolons at the ends of statements 17 | semi: false 18 | 19 | # Wrap prose-like comments as-is 20 | proseWrap: 'always' 21 | 22 | # Format files with Unix-style line endings 23 | endOfLine: 'lf' 24 | 25 | # Print width for formatting 26 | printWidth: 100 27 | 28 | # Arrow function parentheses 29 | arrowParens: 'always' 30 | 31 | -------------------------------------------------------------------------------- /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", "generated/**/*.ts"], 25 | } 26 | -------------------------------------------------------------------------------- /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(`received response status: ${config.status} , data: ${config.data}`) 17 | return config 18 | }, 19 | (error) => { 20 | console.error(error) 21 | }, 22 | ) 23 | } 24 | 25 | public getAxiosInstance(): AxiosInstance { 26 | return this.client 27 | } 28 | } 29 | 30 | export default new APIService() 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/src/middleware/req.res.logger.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express' 2 | import type { NestMiddleware } from '@nestjs/common' 3 | import { Injectable, Logger } from '@nestjs/common' 4 | 5 | @Injectable() 6 | export class HTTPLoggerMiddleware implements NestMiddleware { 7 | private logger = new Logger('HTTP') 8 | 9 | use(request: Request, response: Response, next: NextFunction): void { 10 | const { method, originalUrl } = request 11 | 12 | response.on('finish', () => { 13 | const { statusCode } = response 14 | const contentLength = response.get('content-length') || '-' 15 | const hostedHttpLogFormat = `${method} ${originalUrl} ${statusCode} ${contentLength} - ${request.get( 16 | 'user-agent', 17 | )}` 18 | this.logger.log(hostedHttpLogFormat) 19 | }) 20 | next() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/app.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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: {{ .Release.Name }}.{{ .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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /backend/src/common/logger.config.ts: -------------------------------------------------------------------------------- 1 | import { WinstonModule, utilities } from 'nest-winston' 2 | import * as winston from 'winston' 3 | import type { LoggerService } from '@nestjs/common' 4 | 5 | const globalLoggerFormat: winston.Logform.Format = winston.format.timestamp({ 6 | format: 'YYYY-MM-DD hh:mm:ss.SSS', 7 | }) 8 | 9 | const localLoggerFormat: winston.Logform.Format = winston.format.combine( 10 | winston.format.colorize(), 11 | winston.format.align(), 12 | utilities.format.nestLike('Backend', { prettyPrint: true }), 13 | ) 14 | 15 | export const customLogger: LoggerService = WinstonModule.createLogger({ 16 | transports: [ 17 | new winston.transports.Console({ 18 | level: 'silly', 19 | format: winston.format.combine( 20 | globalLoggerFormat, 21 | localLoggerFormat, 22 | winston.format.colorize({ level: true }), 23 | ), 24 | }), 25 | ], 26 | exitOnError: false, 27 | }) 28 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/prisma.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { defineConfig } from 'prisma/config' 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') 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 PGBOUNCER_URL = process.env.PGBOUNCER_URL 11 | 12 | const dataSourceURL = 13 | process.env.DATABASE_URL || 14 | (PGBOUNCER_URL 15 | ? `${PGBOUNCER_URL}?schema=${DB_SCHEMA}&pgbouncer=true` 16 | : `postgresql://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&connection_limit=5`) 17 | 18 | export default defineConfig({ 19 | schema: 'prisma/schema.prisma', 20 | migrations: { 21 | path: 'prisma/migrations', 22 | }, 23 | datasource: { 24 | url: dataSourceURL, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import swc from "unplugin-swc"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | resolve: { 12 | alias: { 13 | src: path.resolve(__dirname, "src"), 14 | }, 15 | }, 16 | test: { 17 | include: ["**/*.e2e-spec.ts", "**/*.spec.ts"], 18 | exclude: ["**/node_modules/**"], 19 | globals: true, 20 | environment: "node", 21 | coverage: { 22 | provider: "v8", 23 | reporter: ["lcov", "text-summary", "text", "json", "html"], 24 | exclude: [ 25 | '**/node_modules/**', 26 | '**/dist/**', 27 | '**/coverage/**', 28 | '**/*.config.*', 29 | '**/*.spec.ts', 30 | '**/*.e2e-spec.ts', 31 | ], 32 | }, 33 | }, 34 | plugins: [swc.vite()], 35 | }); 36 | -------------------------------------------------------------------------------- /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 | exclude: [ 18 | '**/node_modules/**', 19 | '**/dist/**', 20 | '**/coverage/**', 21 | '**/*.config.*', 22 | 'src/routeTree.gen.ts', // Auto-generated file 23 | 'src/**/*.test.ts', 24 | 'src/**/*.spec.ts', 25 | 'src/**/*.test.tsx', 26 | 'src/**/*.spec.tsx', 27 | 'src/__tests__/**', 28 | ], 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /.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@d9b3d32fb3f03c4699c2dce83ddfff042cd31a1f # v1.0.0 20 | with: 21 | markdown_links: | 22 | - [Frontend](https://${{ github.event.repository.name }}-${{ github.event.number }}.apps.silver.devops.gov.bc.ca) 23 | - [Backend](https://${{ github.event.repository.name }}-${{ github.event.number }}.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 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM node:24.12.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:24.12.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/generated ./generated 27 | COPY --from=build /app/dist ./dist 28 | 29 | # Boilerplate, not used in OpenShift/Kubernetes 30 | EXPOSE 3000 31 | HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/api 32 | 33 | # Nonroot user, limit heap size to 50 MB 34 | USER nonroot 35 | CMD ["--max-old-space-size=50", "/app/dist/main"] 36 | -------------------------------------------------------------------------------- /.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@d9b3d32fb3f03c4699c2dce83ddfff042cd31a1f # v1.0.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@9b776dc20a55f435b7c5024152b6b7b294362809 # v1.2.5 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 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import type { MiddlewareConsumer } from '@nestjs/common' 3 | import { Module, RequestMethod } from '@nestjs/common' 4 | import { HTTPLoggerMiddleware } from './middleware/req.res.logger' 5 | import { PrismaService } from 'src/prisma.service' 6 | import { ConfigModule } from '@nestjs/config' 7 | import { UsersModule } from './users/users.module' 8 | import { AppService } from './app.service' 9 | import { AppController } from './app.controller' 10 | import { MetricsController } from './metrics.controller' 11 | import { TerminusModule } from '@nestjs/terminus' 12 | import { HealthController } from './health.controller' 13 | 14 | @Module({ 15 | imports: [ConfigModule.forRoot(), TerminusModule, UsersModule], 16 | controllers: [AppController, MetricsController, HealthController], 17 | providers: [AppService, PrismaService], 18 | }) 19 | export class AppModule { 20 | // let's add a middleware on all routes 21 | configure(consumer: MiddlewareConsumer) { 22 | consumer 23 | .apply(HTTPLoggerMiddleware) 24 | .exclude( 25 | { path: 'metrics', method: RequestMethod.ALL }, 26 | { path: 'health', method: RequestMethod.ALL }, 27 | ) 28 | .forRoutes('*') 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 type { 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 = await NestFactory.create(AppModule, { 15 | logger: customLogger, 16 | }) 17 | app.use(helmet()) 18 | app.enableCors() 19 | app.set('trust proxy', 1) 20 | app.use(metricsMiddleware) 21 | app.enableShutdownHooks() 22 | app.setGlobalPrefix('api') 23 | app.enableVersioning({ 24 | type: VersioningType.URI, 25 | prefix: 'v', 26 | }) 27 | const config = new DocumentBuilder() 28 | .setTitle('Users example') 29 | .setDescription('The user API description') 30 | .setVersion('1.0') 31 | .addTag('users') 32 | .build() 33 | 34 | const document = SwaggerModule.createDocument(app, config) 35 | SwaggerModule.setup('/api/docs', app, document) 36 | return app 37 | } 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/src/middleware/req.res.logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing' 2 | import { HTTPLoggerMiddleware } from './req.res.logger' 3 | import type { Request, Response } from 'express' 4 | import { Logger } from '@nestjs/common' 5 | 6 | describe('HTTPLoggerMiddleware', () => { 7 | let middleware: HTTPLoggerMiddleware 8 | 9 | beforeEach(async () => { 10 | const module = await Test.createTestingModule({ 11 | providers: [HTTPLoggerMiddleware, Logger], 12 | }).compile() 13 | 14 | middleware = module.get(HTTPLoggerMiddleware) 15 | }) 16 | it('should log the correct information', () => { 17 | const request: Request = { 18 | method: 'GET', 19 | originalUrl: '/test', 20 | get: () => 'Test User Agent', 21 | } as unknown as Request 22 | 23 | const response: Response = { 24 | statusCode: 200, 25 | get: () => '100', 26 | on: (event: string, cb: () => void) => { 27 | if (event === 'finish') { 28 | cb() 29 | } 30 | }, 31 | } as unknown as Response 32 | 33 | const loggerSpy = vi.spyOn(middleware['logger'], 'log') 34 | 35 | middleware.use(request, response, () => {}) 36 | 37 | expect(loggerSpy).toHaveBeenCalledWith(`GET /test 200 100 - Test User Agent`) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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@f900830adadd4d9eef3ca6ff80103e839ba8b7c0 # v1.3.0 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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /eslint-base.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared ESLint base configuration 3 | * Contains common rules and ignore patterns used by both backend and frontend 4 | * 5 | * Each config file imports its own dependencies and applies these rules 6 | */ 7 | 8 | /** 9 | * Shared ignore patterns 10 | */ 11 | export const baseIgnores = [ 12 | // Build tool configs (vite, vitest, playwright, tsconfig) 13 | '**/vite.config.*', 14 | '**/vitest.config.*', 15 | '**/playwright.config.*', 16 | '**/tsconfig*.json', 17 | // Dist, dependencies, and coverage 18 | '**/dist/**', 19 | '**/node_modules/**', 20 | '**/coverage/**', 21 | ] 22 | 23 | /** 24 | * Shared ESLint rules 25 | * These rules are applied to both backend and frontend configurations 26 | */ 27 | export const baseRules = { 28 | // Prettier integration 29 | 'prettier/prettier': 'error', 30 | 31 | // General ESLint rules 32 | 'no-console': 'off', 33 | 'no-debugger': 'warn', 34 | 'no-unused-vars': 'off', 35 | 'no-empty': ['error', { allowEmptyCatch: true }], 36 | 37 | // TypeScript rules (shared across frontend and backend) 38 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 39 | '@typescript-eslint/explicit-module-boundary-types': 'off', 40 | '@typescript-eslint/no-explicit-any': 'off', 41 | '@typescript-eslint/no-non-null-assertion': 'off', 42 | '@typescript-eslint/no-empty-interface': 'off', 43 | '@typescript-eslint/ban-types': 'off', 44 | '@typescript-eslint/explicit-function-return-type': 'off', 45 | // Note: consistent-type-imports is NOT in base config 46 | // - Frontend: Uses it explicitly (safe for React/TypeScript) 47 | // - Backend: Does NOT use it (NestJS DI requires runtime class references) 48 | }; 49 | 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM node:24-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 | # Build custom Caddy with Coraza WAF plugin 10 | FROM caddy:2.10.2-builder AS builder 11 | # Install CA certificates for Go module downloads 12 | RUN apk add --no-cache ca-certificates 13 | RUN xcaddy build \ 14 | --with github.com/corazawaf/coraza-caddy/v2 15 | 16 | # Deploy using custom Caddy to host static files 17 | FROM caddy:2.10.2-alpine 18 | RUN apk add --no-cache ca-certificates 19 | 20 | # Copy custom Caddy binary with Coraza 21 | COPY --from=builder /usr/bin/caddy /usr/bin/caddy 22 | 23 | # Copy static files, Coraza config, verify Caddyfile formatting 24 | COPY --from=build /app/dist /srv 25 | COPY Caddyfile /etc/caddy/Caddyfile 26 | COPY coraza.conf /etc/caddy/coraza.conf 27 | 28 | # Set permissions for static files (OpenShift-compatible) 29 | # OpenShift uses random UIDs but assigns them to root group (GID 0) 30 | # Set ownership to user 1001 with root group, and readable permissions 31 | RUN chown -R 1001:0 /srv && \ 32 | chmod -R 755 /srv 33 | 34 | # Create directories for Coraza WAF with restricted permissions 35 | # OpenShift uses random UIDs, so use root group (GID 0) with group write permissions 36 | # This allows any UID in the root group (which OpenShift assigns to all containers) to access 37 | RUN mkdir -p /tmp/coraza && \ 38 | chown -R 1001:0 /tmp/coraza && \ 39 | chmod -R 770 /tmp/coraza 40 | 41 | RUN caddy fmt /etc/caddy/Caddyfile 42 | 43 | # Boilerplate, not used in OpenShift/Kubernetes 44 | EXPOSE 3000 3001 45 | HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3001/health 46 | 47 | # Nonroot user 48 | USER 1001 49 | -------------------------------------------------------------------------------- /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 { Route as rootRouteImport } from './routes/__root' 12 | import { Route as IndexRouteImport } from './routes/index' 13 | 14 | const IndexRoute = IndexRouteImport.update({ 15 | id: '/', 16 | path: '/', 17 | getParentRoute: () => rootRouteImport, 18 | } as any) 19 | 20 | export interface FileRoutesByFullPath { 21 | '/': typeof IndexRoute 22 | } 23 | export interface FileRoutesByTo { 24 | '/': typeof IndexRoute 25 | } 26 | export interface FileRoutesById { 27 | __root__: typeof rootRouteImport 28 | '/': typeof IndexRoute 29 | } 30 | export interface FileRouteTypes { 31 | fileRoutesByFullPath: FileRoutesByFullPath 32 | fullPaths: '/' 33 | fileRoutesByTo: FileRoutesByTo 34 | to: '/' 35 | id: '__root__' | '/' 36 | fileRoutesById: FileRoutesById 37 | } 38 | export interface RootRouteChildren { 39 | IndexRoute: typeof IndexRoute 40 | } 41 | 42 | declare module '@tanstack/react-router' { 43 | interface FileRoutesByPath { 44 | '/': { 45 | id: '/' 46 | path: '/' 47 | fullPath: '/' 48 | preLoaderRoute: typeof IndexRouteImport 49 | parentRoute: typeof rootRouteImport 50 | } 51 | } 52 | } 53 | 54 | const rootRouteChildren: RootRouteChildren = { 55 | IndexRoute: IndexRoute, 56 | } 57 | export const routeTree = rootRouteImport 58 | ._addFileChildren(rootRouteChildren) 59 | ._addFileTypes() 60 | -------------------------------------------------------------------------------- /frontend/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | metrics 3 | auto_https off 4 | admin 0.0.0.0:3003 5 | order coraza_waf first 6 | } 7 | :3000 { 8 | log { 9 | output stdout 10 | format console 11 | level {$LOG_LEVEL} 12 | } 13 | 14 | # Enable Coraza WAF 15 | coraza_waf { 16 | directives ` 17 | Include /etc/caddy/coraza.conf 18 | ` 19 | } 20 | 21 | root * /srv 22 | encode zstd gzip 23 | file_server 24 | @spa_router { 25 | not path /api* 26 | file { 27 | try_files {path} /index.html 28 | } 29 | } 30 | rewrite @spa_router {http.matchers.file.relative} 31 | # Proxy requests to API service 32 | reverse_proxy /api* {$BACKEND_URL} { 33 | header_up Host {http.reverse_proxy.upstream.hostport} 34 | header_up X-Real-IP {remote_host} 35 | } 36 | header { 37 | -Server 38 | X-Frame-Options "SAMEORIGIN" 39 | X-XSS-Protection "1;mode=block" 40 | Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" 41 | X-Content-Type-Options "nosniff" 42 | Strict-Transport-Security "max-age=31536000" 43 | Content-Security-Policy "default-src 'self' https://*.gov.bc.ca;; 44 | script-src 'self' https://*.gov.bc.ca ; 45 | style-src 'self' https://fonts.googleapis.com https://use.fontawesome.com 'unsafe-inline'; 46 | font-src 'self' https://fonts.gstatic.com; 47 | img-src 'self' data: https://fonts.googleapis.com https://www.w3.org https://*.gov.bc.ca https://*.tile.openstreetmap.org; 48 | frame-ancestors 'self'; 49 | form-action 'self'; 50 | block-all-mixed-content; 51 | connect-src 'self' https://*.gov.bc.ca wss://*.gov.bc.ca;" 52 | Referrer-Policy "same-origin" 53 | Permissions-Policy "fullscreen=(self), camera=(), microphone=()" 54 | Cross-Origin-Resource-Policy "cross-origin" 55 | Cross-Origin-Opener-Policy "same-origin" 56 | } 57 | } 58 | :3001 { 59 | handle /health { 60 | respond "OK" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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@2b24ac7f95e6a019064151498660437cca3202c5 # v4.2.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 | db_triggers: ('charts/crunchy/') 45 | 46 | tests: 47 | name: Tests 48 | if: needs.deploys.outputs.triggered == 'true' 49 | needs: [deploys] 50 | uses: ./.github/workflows/.tests.yml 51 | 52 | results: 53 | name: PR Results 54 | needs: [builds, deploys, tests] 55 | if: always() 56 | runs-on: ubuntu-24.04 57 | steps: 58 | - if: contains(needs.*.result, 'failure')||contains(needs.*.result, 'canceled') 59 | run: echo "At least one job has failed." && exit 1 60 | - run: echo "Success!" 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import prettier from 'eslint-plugin-prettier'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | 6 | /** 7 | * Shared ignore patterns (inlined from eslint-base.config.mjs) 8 | */ 9 | const baseIgnores = [ 10 | // Build tool configs (vite, vitest, playwright, tsconfig) 11 | '**/vite.config.*', 12 | '**/vitest.config.*', 13 | '**/playwright.config.*', 14 | '**/tsconfig*.json', 15 | // Dist, dependencies, and coverage 16 | '**/dist/**', 17 | '**/node_modules/**', 18 | '**/coverage/**', 19 | ]; 20 | 21 | /** 22 | * Shared ESLint rules (inlined from eslint-base.config.mjs) 23 | * Note: consistent-type-imports is NOT included here because NestJS DI requires runtime class references 24 | */ 25 | const baseRules = { 26 | // Prettier integration 27 | 'prettier/prettier': 'error', 28 | 29 | // General ESLint rules 30 | 'no-console': 'off', 31 | 'no-debugger': 'warn', 32 | 'no-unused-vars': 'off', 33 | 'no-empty': ['error', { allowEmptyCatch: true }], 34 | 35 | // TypeScript rules (shared across frontend and backend) 36 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 37 | '@typescript-eslint/explicit-module-boundary-types': 'off', 38 | '@typescript-eslint/no-explicit-any': 'off', 39 | '@typescript-eslint/no-non-null-assertion': 'off', 40 | '@typescript-eslint/no-empty-interface': 'off', 41 | '@typescript-eslint/ban-types': 'off', 42 | '@typescript-eslint/explicit-function-return-type': 'off', 43 | }; 44 | 45 | export default tseslint.config( 46 | eslint.configs.recommended, 47 | ...tseslint.configs.recommended, 48 | prettierConfig, 49 | { 50 | files: ['**/*.ts'], 51 | ignores: [...baseIgnores], 52 | plugins: { 53 | prettier, 54 | }, 55 | rules: { 56 | ...baseRules, 57 | // Additional backend-specific rules can be added here 58 | }, 59 | }, 60 | ); 61 | 62 | -------------------------------------------------------------------------------- /backend/src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Put, 7 | Param, 8 | Delete, 9 | Query, 10 | HttpException, 11 | } from '@nestjs/common' 12 | import { ApiTags } from '@nestjs/swagger' 13 | import { UsersService } from './users.service' 14 | import { CreateUserDto } from './dto/create-user.dto' 15 | import { UpdateUserDto } from './dto/update-user.dto' 16 | import { UserDto } from './dto/user.dto' 17 | 18 | @ApiTags('users') 19 | @Controller({ path: 'users', version: '1' }) 20 | export class UsersController { 21 | constructor(private readonly usersService: UsersService) {} 22 | 23 | @Post() 24 | create(@Body() createUserDto: CreateUserDto) { 25 | return this.usersService.create(createUserDto) 26 | } 27 | 28 | @Get() 29 | findAll(): Promise { 30 | return this.usersService.findAll() 31 | } 32 | 33 | @Get('search') // it must be ahead of the below Get(":id") to avoid conflict 34 | async searchUsers( 35 | @Query('page') page: number, 36 | @Query('limit') limit: number, 37 | @Query('sort') sort: string, // JSON string to store sort key and sort value, ex: {name: "ASC"} 38 | @Query('filter') filter: string, // JSON array for key, operation and value, ex: [{key: "name", operation: "like", value: "Peter"}] 39 | ) { 40 | if (isNaN(page) || isNaN(limit)) { 41 | throw new HttpException('Invalid query parameters', 400) 42 | } 43 | return this.usersService.searchUsers(page, limit, sort, filter) 44 | } 45 | 46 | @Get(':id') 47 | async findOne(@Param('id') id: string) { 48 | const user = await this.usersService.findOne(+id) 49 | if (!user) { 50 | throw new HttpException('User not found.', 404) 51 | } 52 | return user 53 | } 54 | 55 | @Put(':id') 56 | update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { 57 | return this.usersService.update(+id, updateUserDto) 58 | } 59 | 60 | @Delete(':id') 61 | remove(@Param('id') id: string) { 62 | return this.usersService.remove(+id) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.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 | 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 | - [ ] Documentation update 18 | 19 | # How Has This Been Tested? 20 | 21 | 22 | 23 | 24 | 25 | - [ ] New unit tests 26 | - [ ] New integrated tests 27 | - [ ] New component tests 28 | - [ ] New end-to-end tests 29 | - [ ] New user flow tests 30 | - [ ] No new tests are required 31 | - [ ] Manual tests (description below) 32 | - [ ] Updated existing tests 33 | 34 | 35 | ## Checklist 36 | 37 | 38 | 39 | 40 | - [x] I have read the [CONTRIBUTING](CONTRIBUTING.md) doc 41 | - [x] I have performed a self-review of my own code 42 | - [x] I have commented my code, particularly in hard-to-understand areas 43 | - [x] I have made corresponding changes to the documentation 44 | - [x] My changes generate no new warnings 45 | - [x] I have added tests that prove my fix is effective or that my feature works 46 | - [x] New and existing unit tests pass locally with my changes 47 | - [x] Any dependent changes have already been accepted and merged 48 | 49 | 50 | ## Further comments 51 | 52 | 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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: appproxy # appproxy is the user which works with pgbouncer. 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: appproxy # appproxy is the user which works with pgbouncer. 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/src/metrics.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing' 2 | import { Test } from '@nestjs/testing' 3 | import { MetricsController } from './metrics.controller' 4 | import type { INestApplication } from '@nestjs/common' 5 | import request from 'supertest' 6 | 7 | // Mock the prom middleware 8 | vi.mock('src/middleware/prom', () => { 9 | const mockRegister = { 10 | metrics: vi.fn(), 11 | } 12 | return { 13 | register: mockRegister, 14 | } 15 | }) 16 | 17 | describe('MetricsController', () => { 18 | let controller: MetricsController 19 | let app: INestApplication 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | controllers: [MetricsController], 24 | }).compile() 25 | 26 | controller = module.get(MetricsController) 27 | app = module.createNestApplication() 28 | await app.init() 29 | }) 30 | 31 | afterEach(async () => { 32 | await app.close() 33 | vi.clearAllMocks() 34 | }) 35 | 36 | it('should be defined', () => { 37 | expect(controller).toBeDefined() 38 | }) 39 | 40 | describe('GET /metrics', () => { 41 | it('should return metrics from prometheus register', async () => { 42 | // Arrange 43 | const mockMetrics = 'http_requests_total 100\nhttp_requests_duration_seconds 0.5' 44 | const { register } = await import('src/middleware/prom') 45 | vi.mocked(register.metrics).mockResolvedValue(mockMetrics) 46 | 47 | // Act & Assert 48 | return request(app.getHttpServer()) 49 | .get('/metrics') 50 | .expect(200) 51 | .expect(mockMetrics) 52 | .then(() => { 53 | expect(register.metrics).toHaveBeenCalledTimes(1) 54 | }) 55 | }) 56 | 57 | it('should handle errors when metrics collection fails', async () => { 58 | // Arrange 59 | const { register } = await import('src/middleware/prom') 60 | vi.mocked(register.metrics).mockRejectedValue(new Error('Metrics collection failed')) 61 | 62 | // Act & Assert 63 | return request(app.getHttpServer()) 64 | .get('/metrics') 65 | .expect(500) 66 | .then(() => { 67 | expect(register.metrics).toHaveBeenCalledTimes(1) 68 | }) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /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 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 14 | "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", 15 | "test:unit": "vitest --mode test", 16 | "test:cov": "vitest run --mode test --coverage", 17 | "build": "vite build" 18 | }, 19 | "dependencies": { 20 | "@bcgov/bc-sans": "^2.1.0", 21 | "@bcgov/design-system-react-components": "^0.5.0", 22 | "@popperjs/core": "^2.11.8", 23 | "@tanstack/react-router": "^1.114.12", 24 | "@tanstack/router-plugin": "^1.114.27", 25 | "@types/node": "^24.0.0", 26 | "@vitejs/plugin-react": "^5.0.0", 27 | "axios": "^1.7.7", 28 | "bootstrap": "^5.3.3", 29 | "bootstrap-icons": "^1.11.3", 30 | "react": "^19.0.0", 31 | "react-bootstrap": "^2.10.9", 32 | "react-dom": "^19.0.0", 33 | "sass-embedded": "^1.86.0", 34 | "vite": "^7.0.0", 35 | "vite-tsconfig-paths": "^5.1.4" 36 | }, 37 | "devDependencies": { 38 | "@faker-js/faker": "^10.0.0", 39 | "@playwright/test": "^1.51.0", 40 | "@tanstack/react-router-devtools": "^1.114.13", 41 | "@testing-library/jest-dom": "^6.0.0", 42 | "@testing-library/react": "^16.0.0", 43 | "@testing-library/user-event": "^14.4.3", 44 | "@types/react": "^19.0.10", 45 | "@types/react-dom": "^19.0.4", 46 | "@vitest/coverage-v8": "^4.0.0", 47 | "@vitest/ui": "^4.0.0", 48 | "@eslint/js": "^9.38.0", 49 | "eslint": "^9.38.0", 50 | "eslint-config-prettier": "^10.1.8", 51 | "eslint-plugin-prettier": "^5.2.3", 52 | "eslint-plugin-react": "^7.37.4", 53 | "eslint-plugin-react-hooks": "^7.0.0", 54 | "typescript-eslint": "^8.46.2", 55 | "history": "^5.3.0", 56 | "jsdom": "^27.0.0", 57 | "msw": "^2.7.3", 58 | "playwright": "^1.51.0", 59 | "prettier": "^3.5.3", 60 | "sass": "^1.85.1", 61 | "typescript": "^5.7.3", 62 | "vitest": "^4.0.0" 63 | }, 64 | "overrides": { 65 | "rollup@<4.22.4": "^4.22.4", 66 | "@types/react": "^19.0.10" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /backend/src/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' 2 | import { Injectable, Logger } from '@nestjs/common' 3 | import { Prisma, PrismaClient } from '../generated/prisma/client.js' 4 | import { PrismaPg } from '@prisma/adapter-pg' 5 | import { Pool } from 'pg' 6 | 7 | const DB_HOST = process.env.POSTGRES_HOST || 'localhost' 8 | const DB_USER = process.env.POSTGRES_USER || 'postgres' 9 | const DB_PWD = encodeURIComponent(process.env.POSTGRES_PASSWORD || 'default') 10 | const DB_PORT = process.env.POSTGRES_PORT || 5432 11 | const DB_NAME = process.env.POSTGRES_DATABASE || 'postgres' 12 | const DB_SCHEMA = process.env.POSTGRES_SCHEMA || 'users' 13 | const PGBOUNCER_URL = process.env.PGBOUNCER_URL 14 | const dataSourceURL = PGBOUNCER_URL 15 | ? `${PGBOUNCER_URL}?schema=${DB_SCHEMA}&pgbouncer=true` 16 | : `postgresql://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&connection_limit=5` 17 | 18 | @Injectable() 19 | class PrismaService 20 | extends PrismaClient 21 | implements OnModuleInit, OnModuleDestroy 22 | { 23 | private logger = new Logger('PRISMA') 24 | private static instance: PrismaService 25 | private pool: Pool 26 | constructor() { 27 | if (PrismaService.instance) { 28 | return PrismaService.instance 29 | } 30 | const pool = new Pool({ connectionString: dataSourceURL }) 31 | const adapter = new PrismaPg(pool) 32 | super({ 33 | adapter, 34 | errorFormat: 'pretty', 35 | log: [ 36 | { emit: 'event', level: 'query' }, 37 | { emit: 'stdout', level: 'info' }, 38 | { emit: 'stdout', level: 'warn' }, 39 | { emit: 'stdout', level: 'error' }, 40 | ], 41 | }) 42 | this.pool = pool 43 | PrismaService.instance = this 44 | } 45 | 46 | async onModuleInit() { 47 | await this.$connect() 48 | this.$on('query', (e: Prisma.QueryEvent) => { 49 | // dont print the health check queries, which contains SELECT 1 or COMMIT , BEGIN, DEALLOCATE ALL 50 | // this is to avoid logging health check queries which are executed by the framework. 51 | const excludedPatterns = ['COMMIT', 'BEGIN', 'SELECT 1', 'DEALLOCATE ALL'] 52 | if (excludedPatterns.some((pattern) => e?.query?.toUpperCase().includes(pattern))) { 53 | return 54 | } 55 | this.logger.log(`Query: ${e.query} - Params: ${e.params} - Duration: ${e.duration}ms`) 56 | }) 57 | } 58 | 59 | async onModuleDestroy() { 60 | await this.$disconnect() 61 | } 62 | } 63 | 64 | export { PrismaService } 65 | -------------------------------------------------------------------------------- /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 | {{- $pgbouncerUrl := printf ""}} 8 | {{- $secretName := printf "%s-pguser-%s" .Values.global.databaseAlias .Values.global.config.databaseUser }} 9 | {{- $databaseUser = .Values.global.config.databaseUser}} 10 | {{- $secretObj := (lookup "v1" "Secret" .Release.Namespace $secretName ) }} 11 | {{- if not $secretObj }} 12 | {{- fail (printf "Secret %s not found in namespace %s" $secretName .Release.Namespace) }} 13 | {{- end }} 14 | {{- $secretData := (get $secretObj "data") }} 15 | {{- if not $secretData }} 16 | {{- fail (printf "Secret %s data not found in namespace %s" $secretName .Release.Namespace) }} 17 | {{- end }} 18 | {{- $databasePassword = get $secretData "password" }} 19 | {{- $databaseName = b64dec (get $secretData "dbname") }} 20 | {{- $pgbouncerUrl = b64dec (get $secretData "pgbouncer-uri") }} 21 | {{- $host = printf "%s:%s" (b64dec (get $secretData "host")) (b64dec (get $secretData "port")) }} 22 | {{- $hostWithoutPort = printf "%s" (b64dec (get $secretData "host")) }} 23 | {{- $databaseURL := printf "postgresql://%s:%s@%s/%s" $databaseUser (b64dec $databasePassword) $host $databaseName }} 24 | {{- $databaseJDBCURL := printf "jdbc:postgresql://%s:%s@%s/%s" $databaseUser (b64dec $databasePassword) $host $databaseName }} 25 | {{- $databaseJDBCURLNoCreds := printf "jdbc:postgresql://%s/%s" $host $databaseName }} 26 | 27 | --- 28 | apiVersion: v1 29 | kind: Secret 30 | metadata: 31 | name: {{ .Release.Name }}-backend 32 | labels: {{- include "labels" . | nindent 4 }} 33 | {{- if .Values.global.secrets.persist }} 34 | annotations: 35 | helm.sh/resource-policy: keep 36 | {{- end }} 37 | data: 38 | POSTGRES_PASSWORD: {{ $databasePassword | quote }} 39 | POSTGRES_USER: {{ $databaseUser | b64enc | quote }} 40 | POSTGRES_DATABASE: {{ $databaseName | b64enc | quote }} 41 | POSTGRES_HOST: {{ $hostWithoutPort | b64enc | quote }} 42 | PGBOUNCER_URL: {{ $pgbouncerUrl | b64enc | quote }} 43 | --- 44 | apiVersion: v1 45 | kind: Secret 46 | metadata: 47 | name: {{ .Release.Name }}-flyway 48 | labels: {{- include "labels" . | nindent 4 }} 49 | {{- if .Values.global.secrets.persist }} 50 | annotations: 51 | helm.sh/resource-policy: keep 52 | {{- end }} 53 | data: 54 | FLYWAY_URL: {{ $databaseJDBCURLNoCreds | b64enc | quote }} 55 | FLYWAY_USER: {{ $databaseUser | b64enc | quote }} 56 | FLYWAY_PASSWORD: {{ $databasePassword | quote }} 57 | {{- end }} 58 | -------------------------------------------------------------------------------- /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 | Row Details 25 | 26 | {JSON.stringify(user)} 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | const Dashboard: FC = () => { 35 | const [data, setData] = useState([]) 36 | const [selectedUser, setSelectedUser] = useState(undefined) 37 | 38 | useEffect(() => { 39 | apiService 40 | .getAxiosInstance() 41 | .get('/v1/users') 42 | .then((response: AxiosResponse) => { 43 | const users: UserDto[] = [] 44 | for (const user of response.data) { 45 | const userDto = { 46 | id: user.id, 47 | name: user.name, 48 | email: user.email, 49 | } 50 | users.push(userDto) 51 | } 52 | setData(users) 53 | }) 54 | .catch((error) => { 55 | console.error(error) 56 | }) 57 | }, []) 58 | 59 | const handleClose = () => { 60 | setSelectedUser(undefined) 61 | } 62 | 63 | return ( 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 73 | 74 | 75 | {data.map((user: UserDto) => ( 76 | 77 | 78 | 79 | 80 | 85 | 86 | ))} 87 | 88 |
Employee IDEmployee NameEmployee Email 72 |
{user.id}{user.name}{user.email} 81 | 84 |
89 | 90 |
91 | ) 92 | } 93 | 94 | export default Dashboard 95 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import react from 'eslint-plugin-react'; 4 | import reactHooks from 'eslint-plugin-react-hooks'; 5 | import prettier from 'eslint-plugin-prettier'; 6 | import prettierConfig from 'eslint-config-prettier'; 7 | 8 | /** 9 | * Shared ignore patterns (inlined from eslint-base.config.mjs) 10 | */ 11 | const baseIgnores = [ 12 | // Build tool configs (vite, vitest, playwright, tsconfig) 13 | '**/vite.config.*', 14 | '**/vitest.config.*', 15 | '**/playwright.config.*', 16 | '**/tsconfig*.json', 17 | // Dist, dependencies, and coverage 18 | '**/dist/**', 19 | '**/node_modules/**', 20 | '**/coverage/**', 21 | ]; 22 | 23 | /** 24 | * Shared ESLint rules (inlined from eslint-base.config.mjs) 25 | */ 26 | const baseRules = { 27 | // Prettier integration 28 | 'prettier/prettier': 'error', 29 | 30 | // General ESLint rules 31 | 'no-console': 'off', 32 | 'no-debugger': 'warn', 33 | 'no-unused-vars': 'off', 34 | 'no-empty': ['error', { allowEmptyCatch: true }], 35 | 36 | // TypeScript rules (shared across frontend and backend) 37 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 38 | '@typescript-eslint/explicit-module-boundary-types': 'off', 39 | '@typescript-eslint/no-explicit-any': 'off', 40 | '@typescript-eslint/no-non-null-assertion': 'off', 41 | '@typescript-eslint/no-empty-interface': 'off', 42 | '@typescript-eslint/ban-types': 'off', 43 | '@typescript-eslint/explicit-function-return-type': 'off', 44 | }; 45 | 46 | export default tseslint.config( 47 | eslint.configs.recommended, 48 | ...tseslint.configs.recommended, 49 | prettierConfig, 50 | { 51 | files: ['**/*.ts', '**/*.tsx'], 52 | ignores: [ 53 | ...baseIgnores, 54 | '**/public/**', 55 | 'src/routeTree.gen.ts', // Auto-generated file 56 | ], 57 | plugins: { 58 | react, 59 | 'react-hooks': reactHooks, 60 | prettier, 61 | }, 62 | settings: { 63 | react: { 64 | version: 'detect', 65 | }, 66 | }, 67 | rules: { 68 | ...baseRules, 69 | // Additional frontend-specific rules 70 | 'no-use-before-define': 'off', 71 | '@typescript-eslint/no-use-before-define': ['error', { functions: false }], 72 | '@typescript-eslint/no-var-requires': 'off', 73 | 74 | // Type-only imports are safe in frontend (no runtime DI like NestJS) 75 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], 76 | 77 | // React rules (preserved from .eslintrc.yml) 78 | ...react.configs.recommended.rules, 79 | ...reactHooks.configs.recommended.rules, 80 | 'react/jsx-uses-react': 'off', 81 | 'react/react-in-jsx-scope': 'off', 82 | 'react/prop-types': 'off', 83 | 'react/display-name': 'off', 84 | }, 85 | }, 86 | ); 87 | 88 | -------------------------------------------------------------------------------- /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 | "postbuild": "npx swc generated/prisma -d dist/generated/prisma -C module.type=commonjs && find dist/generated/prisma/generated/prisma -name '*.js' -type f -exec sh -c 'relpath=\"${1#dist/generated/prisma/generated/prisma/}\"; target=\"generated/prisma/$relpath\"; mkdir -p \"$(dirname \"$target\")\"; cp \"$1\" \"$target\"' _ {} \\; && mkdir -p dist/generated && cp -r generated/prisma dist/generated/", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "deploy": "npm ci --ignore-scripts --no-update-notifier && npm run build", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", 17 | "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 18 | "make-badges": "istanbul-badges-readme --logo=vitest --exitCode=1", 19 | "make-badges:ci": "npm run make-badges -- --ci", 20 | "test": "vitest --dir src", 21 | "test:cov": "prisma generate && vitest run --coverage", 22 | "test:e2e": "vitest --dir test" 23 | }, 24 | "dependencies": { 25 | "@nestjs/cli": "^11.0.2", 26 | "@nestjs/common": "^11.0.6", 27 | "@nestjs/config": "^4.0.0", 28 | "@nestjs/core": "^11.0.6", 29 | "@nestjs/platform-express": "^11.0.6", 30 | "@nestjs/schematics": "^11.0.0", 31 | "@nestjs/swagger": "^11.0.3", 32 | "@nestjs/terminus": "^11.0.0", 33 | "@nestjs/testing": "^11.0.6", 34 | "@prisma/client": "^7.0.0", 35 | "@prisma/adapter-pg": "^7.0.0", 36 | "prisma": "^7.0.0", 37 | "dotenv": "^17.0.0", 38 | "express-prom-bundle": "^8.0.0", 39 | "helmet": "^8.0.0", 40 | "nest-winston": "^1.10.1", 41 | "pg": "^8.13.1", 42 | "prom-client": "^15.1.3", 43 | "reflect-metadata": "^0.2.2", 44 | "rimraf": "^6.0.1", 45 | "swagger-ui-express": "^5.0.1", 46 | "winston": "^3.17.0" 47 | }, 48 | "devDependencies": { 49 | "@swc/cli": "^0.7.0", 50 | "@swc/core": "^1.10.12", 51 | "@types/express": "^5.0.0", 52 | "@types/node": "^24.0.0", 53 | "@types/supertest": "^6.0.2", 54 | "@eslint/js": "^9.38.0", 55 | "@vitest/coverage-v8": "^4.0.0", 56 | "eslint": "^9.38.0", 57 | "eslint-config-prettier": "^10.1.8", 58 | "eslint-plugin-prettier": "^5.2.3", 59 | "typescript-eslint": "^8.46.2", 60 | "istanbul-badges-readme": "^1.9.0", 61 | "prettier": "^3.5.3", 62 | "source-map-support": "^0.5.21", 63 | "supertest": "^7.0.0", 64 | "ts-loader": "^9.5.2", 65 | "ts-node": "^10.9.2", 66 | "tsconfig-paths": "^4.2.0", 67 | "typescript": "^5.2.2", 68 | "unplugin-swc": "^1.5.1", 69 | "vitest": "^4.0.0" 70 | }, 71 | "overrides": { 72 | "minimist@<1.2.6": "1.2.6", 73 | "reflect-metadata": "^0.2.2" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | permissions: {} 17 | 18 | jobs: 19 | integration-tests: 20 | name: Integration 21 | runs-on: ubuntu-24.04 22 | timeout-minutes: 1 23 | steps: 24 | - uses: actions/checkout@v6 25 | - id: cache-npm 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.npm 29 | key: ${{ runner.os }}-build-cache-node-modules-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.os }}-build-cache-node-modules- 32 | ${{ runner.os }}-build- 33 | ${{ runner.os }}- 34 | 35 | - env: 36 | API_NAME: nest 37 | BASE_URL: https://${{ env.PREFIX }}.${{ env.DOMAIN }} 38 | run: | 39 | cd tests/integration 40 | npm ci 41 | node src/main.js 42 | 43 | e2e-tests: 44 | name: E2E 45 | defaults: 46 | run: 47 | working-directory: frontend 48 | runs-on: ubuntu-24.04 49 | timeout-minutes: 10 50 | strategy: 51 | matrix: 52 | project: [chromium, firefox, safari] 53 | steps: 54 | - uses: actions/checkout@v6 55 | name: Checkout 56 | - uses: actions/setup-node@v6 57 | name: Setup Node 58 | with: 59 | node-version: 24 60 | cache: "npm" 61 | cache-dependency-path: frontend/package-lock.json 62 | - name: Install dependencies 63 | run: | 64 | npm ci 65 | npx playwright install --with-deps 66 | 67 | - name: Run Tests 68 | env: 69 | E2E_BASE_URL: https://${{ github.event.repository.name }}-${{ inputs.target }}.${{ env.DOMAIN }}/ 70 | CI: "true" 71 | run: | 72 | npx playwright test --project="${{ matrix.project }}" --reporter=html 73 | 74 | - uses: actions/upload-artifact@v5 75 | if: (! cancelled()) 76 | name: upload results 77 | with: 78 | name: playwright-report-${{ matrix.project }} 79 | path: "./frontend/playwright-report" # path from current folder 80 | retention-days: 7 81 | 82 | load-tests: 83 | name: Load 84 | runs-on: ubuntu-24.04 85 | strategy: 86 | matrix: 87 | name: [backend, frontend] 88 | steps: 89 | - uses: actions/checkout@v6 90 | - uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0 91 | - uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d # v1.3.1 92 | env: 93 | BACKEND_URL: https://${{ env.PREFIX }}.${{ env.DOMAIN }}/api 94 | FRONTEND_URL: https://${{ env.PREFIX }}.${{ env.DOMAIN }} 95 | with: 96 | path: ./tests/load/${{ matrix.name }}-test.js 97 | flags: --vus 100 --duration 300s 98 | -------------------------------------------------------------------------------- /.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@f900830adadd4d9eef3ca6ff80103e839ba8b7c0 # v1.3.0 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@d9b3d32fb3f03c4699c2dce83ddfff042cd31a1f # v1.0.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@3c58388149901b9a03b7718852c5ba889646c27c # v0.13.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.apps.silver.devops.gov.bc.ca/${{ matrix.path }} 87 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Reusable vars 2 | x-var: 3 | - &POSTGRES_USER postgres 4 | - &POSTGRES_PASSWORD default 5 | - &POSTGRES_DATABASE postgres 6 | - &node-image node:22 7 | 8 | # Reusable envars for postgres 9 | x-postgres-vars: &postgres-vars 10 | POSTGRES_HOST: database 11 | POSTGRES_USER: *POSTGRES_USER 12 | POSTGRES_PASSWORD: *POSTGRES_PASSWORD 13 | POSTGRES_DATABASE: *POSTGRES_DATABASE 14 | 15 | services: 16 | database: 17 | image: postgis/postgis:17-3.4 # if using crunchy , make sure to align with crunchy version, currently it is at 16 and postgis 3.3 18 | container_name: database 19 | environment: 20 | <<: *postgres-vars 21 | healthcheck: 22 | test: ["CMD", "pg_isready", "-U", *POSTGRES_USER] 23 | ports: ["5432:5432"] 24 | 25 | migrations: 26 | image: flyway/flyway:11-alpine 27 | container_name: migrations 28 | command: info migrate info 29 | volumes: ["./migrations/sql:/flyway/sql:ro"] 30 | environment: 31 | FLYWAY_URL: jdbc:postgresql://database:5432/postgres 32 | FLYWAY_USER: *POSTGRES_USER 33 | FLYWAY_PASSWORD: *POSTGRES_PASSWORD 34 | FLYWAY_BASELINE_ON_MIGRATE: true 35 | FLYWAY_DEFAULT_SCHEMA: users 36 | depends_on: 37 | database: 38 | condition: service_healthy 39 | 40 | schemaspy: 41 | image: schemaspy/schemaspy:7.0.2 42 | profiles: ["schemaspy"] 43 | container_name: schemaspy 44 | command: -t pgsql11 -db postgres -host database -port 5432 -u postgres -p default -schemas users 45 | depends_on: 46 | migrations: 47 | condition: service_completed_successfully 48 | volumes: ["./output:/output"] 49 | 50 | backend: 51 | container_name: backend 52 | depends_on: 53 | migrations: 54 | condition: service_started 55 | entrypoint: sh -c "npm i && npm run start:dev" 56 | environment: 57 | <<: *postgres-vars 58 | NODE_ENV: development 59 | image: *node-image 60 | ports: ["3001:3000"] 61 | healthcheck: 62 | test: ["CMD", "curl", "-f", "http://localhost:3000/api"] 63 | working_dir: "/app" 64 | volumes: ["./backend:/app", "/app/node_modules"] 65 | 66 | frontend: 67 | container_name: frontend 68 | entrypoint: sh -c "npm ci --ignore-scripts && npm run dev" 69 | environment: 70 | BACKEND_URL: http://backend:3000 71 | PORT: 3000 72 | NODE_ENV: development 73 | image: *node-image 74 | ports: ["3000:3000"] 75 | volumes: ["./frontend:/app", "/app/node_modules"] 76 | healthcheck: 77 | test: ["CMD", "curl", "-f", "http://localhost:3000"] 78 | working_dir: "/app" 79 | depends_on: 80 | backend: 81 | condition: service_healthy 82 | 83 | caddy: 84 | container_name: caddy 85 | profiles: ["caddy"] 86 | build: ./frontend 87 | environment: 88 | NODE_ENV: development 89 | PORT: 3000 90 | BACKEND_URL: http://backend:3000 91 | LOG_LEVEL: info 92 | ports: ["3005:3000"] 93 | volumes: ["./frontend/Caddyfile:/etc/caddy/Caddyfile"] 94 | healthcheck: 95 | test: ["CMD", "curl", "-f", "http://localhost:3000"] 96 | depends_on: 97 | backend: 98 | condition: service_healthy 99 | -------------------------------------------------------------------------------- /frontend/coraza.conf: -------------------------------------------------------------------------------- 1 | # Coraza WAF Configuration 2 | # Basic WAF configuration with security rules 3 | 4 | # Enable rules engine 5 | SecRuleEngine On 6 | 7 | # Request body handling 8 | # Request body limits: 9 | # - File uploads: ~12.5MB (SecRequestBodyLimit 13107200) 10 | # - Non-file payloads (JSON, form data): 512KB (SecRequestBodyNoFilesLimit 524288) 11 | # Adjust based on your application's needs (file uploads, API payloads, etc.) 12 | SecRequestBodyAccess On 13 | SecRequestBodyLimit 13107200 14 | SecRequestBodyNoFilesLimit 524288 15 | 16 | # Response body handling 17 | # Disabled since no response body inspection rules are configured 18 | # Enable if you plan to add response body inspection rules in the future 19 | # When enabled, configure SecResponseBodyMimeType and SecResponseBodyLimit 20 | SecResponseBodyAccess Off 21 | 22 | # File upload handling 23 | # Directories are created in the Dockerfile with OpenShift-compatible permissions (770, GID 0) 24 | # In production, consider using persistent volumes with restricted permissions 25 | SecTmpDir /tmp/coraza/ 26 | SecDataDir /tmp/coraza/ 27 | 28 | # Logging 29 | # RelevantOnly: Only logs transactions that trigger rules or cause errors 30 | # This reduces log volume but means allowed requests won't have audit trails 31 | # For full audit trails (compliance), use SecAuditEngine On with log rotation 32 | SecAuditEngine RelevantOnly 33 | SecAuditLogParts ABIJDEFHZ 34 | SecAuditLogType Serial 35 | SecAuditLog /dev/stdout 36 | 37 | # Debug log (disabled in production with level 0) 38 | SecDebugLog /dev/stdout 39 | SecDebugLogLevel 0 40 | 41 | # Generic attack detection using Coraza's built-in operators 42 | SecRule REQUEST_HEADERS|ARGS|ARGS_NAMES "@detectSQLi" \ 43 | "id:1001,phase:2,block,log,msg:'SQL Injection Attack Detected'" 44 | 45 | SecRule REQUEST_HEADERS|ARGS|ARGS_NAMES "@detectXSS" \ 46 | "id:1002,phase:2,block,log,msg:'XSS Attack Detected'" 47 | 48 | # Block common security scanners by User-Agent with word boundaries 49 | # Note: Burp Suite is excluded to allow authorized security testing 50 | SecRule REQUEST_HEADERS:User-Agent "@rx (?i)\b(nikto|sqlmap|nmap|masscan|nessus|openvas|acunetix)\b" \ 51 | "id:1003,phase:1,deny,log,msg:'Known Security Scanner Detected',status:403" 52 | 53 | # Block access to sensitive paths - anchored to beginning of URI 54 | # This intentionally blocks common paths targeted by attackers (/.env, /.git, /admin, etc.) 55 | # The pattern matches both /path and /path/ for comprehensive protection 56 | # If your application legitimately uses any of these paths, whitelist them using: 57 | # SecRuleRemoveById 1004 58 | # SecRule REQUEST_URI "@rx ^/your-legitimate-path" "id:1004,phase:1,pass" 59 | # Or modify this rule to exclude specific paths from the block list 60 | SecRule REQUEST_URI "@rx ^/(admin|config|backup|tmp|logs|wp-admin|\.env|\.git|\.htaccess|\.htpasswd|\.ssh|\.aws|web\.config)(/|$)" \ 61 | "id:1004,phase:1,deny,log,msg:'Access to sensitive path blocked',status:403" 62 | 63 | # Common attack patterns - SQL Injection (enhanced with comments and procedure calls) 64 | # Uses word boundaries and whitespace requirements to reduce false positives 65 | # SQL comment patterns: --(\s|$) matches comments with whitespace or end of string 66 | # /\*[^*]*\*+/ matches SQL block comments (safer regex to prevent ReDoS) 67 | SecRule ARGS "@rx (?i)(union\s+select|insert\s+into|delete\s+from|drop\s+table|update\s+set|exec\s*\(|execute\s*\(|\bsp_|\bxp_|--(\s|$)|/\*[^*]*\*+/)" \ 68 | "id:1005,phase:2,block,log,msg:'SQL Injection Pattern Detected',status:403" 69 | 70 | # Path traversal protection - comprehensive encoding variations 71 | SecRule REQUEST_URI|ARGS "@rx (?i)(\.\.(/|\\|%2f|%5c)|(%2e%2e|%252e%252e)(/|\\|%2f|%5c))" \ 72 | "id:1007,phase:2,block,log,msg:'Path Traversal Attempt Detected',status:403" 73 | 74 | # Null byte injection 75 | SecRule REQUEST_URI|ARGS|REQUEST_HEADERS "@rx \x00" \ 76 | "id:1008,phase:2,block,log,msg:'Null Byte Injection Detected',status:403" 77 | -------------------------------------------------------------------------------- /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 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "frontend.labels" . | nindent 4 }} 9 | spec: 10 | strategy: 11 | type: {{ .Values.frontend.deploymentStrategy }} 12 | {{- if not .Values.frontend.autoscaling.enabled }} 13 | replicas: {{ .Values.frontend.replicaCount }} 14 | {{- end }} 15 | selector: 16 | matchLabels: 17 | {{- include "frontend.selectorLabels" . | nindent 6 }} 18 | template: 19 | metadata: 20 | annotations: 21 | rollme: {{ randAlphaNum 5 | quote }} 22 | prometheus.io/scrape: 'true' 23 | prometheus.io/port: '3003' 24 | prometheus.io/path: '/metrics' 25 | labels: 26 | {{- include "frontend.labels" . | nindent 8 }} 27 | spec: 28 | {{- if .Values.frontend.podSecurityContext }} 29 | securityContext: 30 | {{- toYaml .Values.frontend.podSecurityContext | nindent 12 }} 31 | {{- end }} 32 | containers: 33 | - name: {{ include "frontend.fullname" . }} 34 | securityContext: 35 | capabilities: 36 | add: [ "NET_BIND_SERVICE" ] 37 | image: "{{.Values.global.registry}}/{{.Values.global.repository}}/frontend:{{ .Values.global.tag | default .Chart.AppVersion }}" 38 | imagePullPolicy: {{ default "Always" .Values.frontend.imagePullPolicy }} 39 | env: 40 | - name: BACKEND_URL 41 | value: "http://{{ .Release.Name }}-backend" 42 | - name: LOG_LEVEL 43 | value: "info" 44 | ports: 45 | - name: http 46 | containerPort: 3000 47 | protocol: TCP 48 | startupProbe: 49 | httpGet: 50 | path: / 51 | port: 3000 52 | scheme: HTTP 53 | initialDelaySeconds: 5 54 | periodSeconds: 5 55 | timeoutSeconds: 2 56 | successThreshold: 1 57 | failureThreshold: 5 58 | readinessProbe: 59 | httpGet: 60 | path: /health 61 | port: 3001 62 | scheme: HTTP 63 | initialDelaySeconds: 5 64 | periodSeconds: 2 65 | timeoutSeconds: 2 66 | successThreshold: 1 67 | failureThreshold: 30 68 | #-- 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 69 | livenessProbe: 70 | successThreshold: 1 71 | failureThreshold: 3 72 | httpGet: 73 | path: /health 74 | port: 3001 75 | scheme: HTTP 76 | initialDelaySeconds: 15 77 | periodSeconds: 30 78 | timeoutSeconds: 5 79 | resources: 80 | requests: 81 | cpu: 30m 82 | memory: 50Mi 83 | volumeMounts: 84 | - name: data 85 | mountPath: /data 86 | - name: config 87 | mountPath: /config 88 | volumes: 89 | - name: data 90 | emptyDir: {} 91 | - name: config 92 | emptyDir: {} 93 | affinity: 94 | podAntiAffinity: 95 | requiredDuringSchedulingIgnoredDuringExecution: 96 | - labelSelector: 97 | matchExpressions: 98 | - key: app.kubernetes.io/name 99 | operator: In 100 | values: 101 | - {{ include "frontend.fullname" . }} 102 | - key: app.kubernetes.io/instance 103 | operator: In 104 | values: 105 | - {{ .Release.Name }} 106 | topologyKey: "kubernetes.io/hostname" 107 | 108 | {{- end }} 109 | -------------------------------------------------------------------------------- /.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@c20d16c26d9b7e6e486f01702880053ed4ebdc91 # v1.5.0 38 | env: 39 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} 40 | with: 41 | commands: | 42 | npm ci 43 | npm run lint 44 | npm run test:cov 45 | dir: backend 46 | node_version: "22" 47 | sonar_args: > 48 | -Dsonar.exclusions=**/coverage/**,**/node_modules/**,**/*spec.ts 49 | -Dsonar.organization=bcgov-sonarcloud 50 | -Dsonar.projectKey=quickstart-openshift_backend 51 | -Dsonar.sources=src 52 | -Dsonar.tests.inclusions=**/*spec.ts 53 | -Dsonar.javascript.lcov.reportPaths=./coverage/lcov.info 54 | sonar_token: ${{ env.SONAR_TOKEN }} 55 | supply_scan: true 56 | triggers: ('backend/') 57 | 58 | frontend-tests: 59 | name: Frontend Tests 60 | if: (! github.event.pull_request.draft) 61 | runs-on: ubuntu-24.04 62 | timeout-minutes: 5 63 | steps: 64 | - uses: bcgov/action-test-and-analyse@c20d16c26d9b7e6e486f01702880053ed4ebdc91 # v1.5.0 65 | env: 66 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} 67 | with: 68 | commands: | 69 | npm ci 70 | npm run lint 71 | npm run test:cov 72 | dir: frontend 73 | node_version: "22" 74 | sonar_args: > 75 | -Dsonar.exclusions=**/coverage/**,**/node_modules/**,**/*spec.ts 76 | -Dsonar.organization=bcgov-sonarcloud 77 | -Dsonar.projectKey=quickstart-openshift_frontend 78 | -Dsonar.sources=src 79 | -Dsonar.tests.inclusions=**/*spec.ts 80 | -Dsonar.javascript.lcov.reportPaths=./coverage/lcov.info 81 | sonar_token: ${{ env.SONAR_TOKEN }} 82 | supply_scan: true 83 | triggers: ('frontend/') 84 | 85 | # https://github.com/marketplace/actions/aqua-security-trivy 86 | trivy: 87 | name: Trivy Security Scan 88 | if: (! github.event.pull_request.draft) 89 | continue-on-error: true 90 | permissions: 91 | security-events: write 92 | runs-on: ubuntu-24.04 93 | timeout-minutes: 1 94 | steps: 95 | - uses: actions/checkout@v6 96 | - name: Run Trivy vulnerability scanner in repo mode 97 | uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 98 | with: 99 | format: "sarif" 100 | output: "trivy-results.sarif" 101 | ignore-unfixed: true 102 | scan-type: "fs" 103 | scanners: "vuln,secret,config" 104 | severity: "CRITICAL,HIGH" 105 | 106 | - name: Upload Trivy scan results to GitHub Security tab 107 | uses: github/codeql-action/upload-sarif@v4 108 | with: 109 | sarif_file: "trivy-results.sarif" 110 | 111 | results: 112 | name: Analysis Results 113 | needs: [backend-tests, frontend-tests] 114 | if: (! github.event.pull_request.draft) 115 | runs-on: ubuntu-24.04 116 | steps: 117 | - if: contains(needs.*.result, 'failure')||contains(needs.*.result, 'canceled') 118 | run: echo "At least one job has failed." && exit 1 119 | - run: echo "Success!" 120 | -------------------------------------------------------------------------------- /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 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "backend.labels" . | nindent 4 }} 9 | spec: 10 | strategy: 11 | type: {{ .Values.backend.deploymentStrategy }} 12 | {{- if not .Values.backend.autoscaling.enabled }} 13 | replicas: {{ .Values.backend.replicaCount }} 14 | {{- end }} 15 | selector: 16 | matchLabels: 17 | {{- include "backend.selectorLabels" . | nindent 6 }} 18 | template: 19 | metadata: 20 | annotations: 21 | rollme: {{ randAlphaNum 5 | quote }} 22 | prometheus.io/scrape: 'true' 23 | prometheus.io/port: '3000' 24 | prometheus.io/path: '/api/metrics' 25 | labels: 26 | {{- include "backend.labels" . | nindent 8 }} 27 | spec: 28 | {{- if .Values.backend.podSecurityContext }} 29 | securityContext: 30 | {{- toYaml .Values.backend.podSecurityContext | nindent 12 }} 31 | {{- end }} 32 | initContainers: 33 | - name: {{ include "backend.fullname" . }}-init 34 | image: "{{.Values.global.registry}}/{{.Values.global.repository}}/migrations:{{ .Values.global.tag | default .Chart.AppVersion }}" 35 | imagePullPolicy: {{ default "Always" .Values.backend.imagePullPolicy }} 36 | envFrom: 37 | - secretRef: 38 | name: {{.Release.Name}}-flyway 39 | env: 40 | - name: FLYWAY_BASELINE_ON_MIGRATE 41 | value: "true" 42 | - name: FLYWAY_DEFAULT_SCHEMA 43 | value: "users" 44 | - name: FLYWAY_CONNECT_RETRIES 45 | value: "10" 46 | - name: FLYWAY_GROUP 47 | value: "true" 48 | resources: 49 | requests: 50 | cpu: 50m 51 | memory: 75Mi 52 | containers: 53 | - name: {{ include "backend.fullname" . }} 54 | {{- if .Values.backend.securityContext }} 55 | securityContext: 56 | {{- toYaml .Values.backend.securityContext | nindent 12 }} 57 | {{- end }} 58 | image: "{{.Values.global.registry}}/{{.Values.global.repository}}/backend:{{ .Values.global.tag | default .Chart.AppVersion }}" 59 | imagePullPolicy: {{ default "Always" .Values.backend.imagePullPolicy }} 60 | envFrom: 61 | - secretRef: 62 | name: {{.Release.Name}}-backend 63 | env: 64 | - name: LOG_LEVEL 65 | value: info 66 | ports: 67 | - name: http 68 | containerPort: {{ .Values.backend.service.targetPort }} 69 | protocol: TCP 70 | startupProbe: 71 | httpGet: 72 | path: /api/health 73 | port: http 74 | scheme: HTTP 75 | initialDelaySeconds: 5 76 | periodSeconds: 5 77 | timeoutSeconds: 2 78 | successThreshold: 1 79 | failureThreshold: 10 80 | readinessProbe: 81 | httpGet: 82 | path: /api/health 83 | port: http 84 | scheme: HTTP 85 | initialDelaySeconds: 5 86 | periodSeconds: 2 87 | timeoutSeconds: 2 88 | successThreshold: 1 89 | failureThreshold: 30 90 | livenessProbe: 91 | successThreshold: 1 92 | failureThreshold: 3 93 | httpGet: 94 | path: /api/health 95 | port: 3000 96 | scheme: HTTP 97 | initialDelaySeconds: 15 98 | periodSeconds: 30 99 | timeoutSeconds: 5 100 | resources: # this is optional 101 | requests: 102 | cpu: 50m 103 | memory: 75Mi 104 | {{- with .Values.backend.nodeSelector }} 105 | nodeSelector: 106 | {{- toYaml . | nindent 8 }} 107 | {{- end }} 108 | {{- with .Values.backend.tolerations }} 109 | tolerations: 110 | {{- toYaml . | nindent 8 }} 111 | {{- end }} 112 | affinity: 113 | podAntiAffinity: 114 | requiredDuringSchedulingIgnoredDuringExecution: 115 | - labelSelector: 116 | matchExpressions: 117 | - key: app.kubernetes.io/name 118 | operator: In 119 | values: 120 | - {{ include "backend.fullname" . }} 121 | - key: app.kubernetes.io/instance 122 | operator: In 123 | values: 124 | - {{ .Release.Name }} 125 | topologyKey: "kubernetes.io/hostname" 126 | 127 | {{- end }} 128 | -------------------------------------------------------------------------------- /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 '../../generated/prisma/client.js' 8 | 9 | @Injectable() 10 | export class UsersService { 11 | constructor(private prisma: PrismaService) {} 12 | 13 | async create(user: CreateUserDto): Promise { 14 | const savedUser = await this.prisma.users.create({ 15 | data: { 16 | name: user.name, 17 | email: user.email, 18 | }, 19 | }) 20 | 21 | return { 22 | id: savedUser.id.toNumber(), 23 | name: savedUser.name, 24 | email: savedUser.email, 25 | } 26 | } 27 | 28 | async findAll(): Promise { 29 | const users = await this.prisma.users.findMany() 30 | return users.flatMap((user) => { 31 | const userDto: UserDto = { 32 | id: user.id.toNumber(), 33 | name: user.name, 34 | email: user.email, 35 | } 36 | return userDto 37 | }) 38 | } 39 | 40 | async findOne(id: number): Promise { 41 | const user = await this.prisma.users.findUnique({ 42 | where: { 43 | id: new Prisma.Decimal(id), 44 | }, 45 | }) 46 | return { 47 | id: user.id.toNumber(), 48 | name: user.name, 49 | email: user.email, 50 | } 51 | } 52 | 53 | async update(id: number, updateUserDto: UpdateUserDto): Promise { 54 | const user = await this.prisma.users.update({ 55 | where: { 56 | id: new Prisma.Decimal(id), 57 | }, 58 | data: { 59 | name: updateUserDto.name, 60 | email: updateUserDto.email, 61 | }, 62 | }) 63 | return { 64 | id: user.id.toNumber(), 65 | name: user.name, 66 | email: user.email, 67 | } 68 | } 69 | 70 | async remove(id: number): Promise<{ deleted: boolean; message?: string }> { 71 | try { 72 | await this.prisma.users.delete({ 73 | where: { 74 | id: new Prisma.Decimal(id), 75 | }, 76 | }) 77 | return { deleted: true } 78 | } catch (err) { 79 | const message = err instanceof Error ? err.message : String(err) 80 | return { deleted: false, message } 81 | } 82 | } 83 | 84 | async searchUsers( 85 | page: number, 86 | limit: number, 87 | sort: string, // JSON string to store sort key and sort value, ex: [{"name":"desc"},{"email":"asc"}] 88 | filter: string, // JSON array for key, operation and value, ex: [{"key": "name", "operation": "like", "value": "Jo"}] 89 | ): Promise { 90 | page = page || 1 91 | if (!limit || limit > 200) { 92 | limit = 10 93 | } 94 | 95 | let sortObj: unknown[] = [] 96 | let filterObj: Array<{ key: string; operation: string; value: unknown }> = [] 97 | try { 98 | sortObj = JSON.parse(sort) 99 | const parsedFilter = JSON.parse(filter) 100 | // Ensure filterObj is an array 101 | filterObj = Array.isArray(parsedFilter) ? parsedFilter : [] 102 | } catch { 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( 127 | filterObj: Array<{ key: string; operation: string; value: unknown }>, 128 | ): Record { 129 | const prismaFilterObj: Record = {} 130 | 131 | for (const item of filterObj) { 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 | -------------------------------------------------------------------------------- /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 | openshift: true 10 | imagePullPolicy: IfNotPresent 11 | # enable below to start a new crunchy cluster after disaster from a backed-up location, crunchy will choose the best place to recover from. 12 | # follow https://access.crunchydata.com/documentation/postgres-operator/5.2.0/tutorial/disaster-recovery/ 13 | # Clone From Backups Stored in S3 / GCS / Azure Blob Storage 14 | clone: 15 | enabled: false 16 | s3: 17 | enabled: false 18 | pvc: 19 | enabled: false 20 | 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 21 | # enable this to go back to a specific timestamp in history in the current cluster. 22 | # follow https://access.crunchydata.com/documentation/postgres-operator/5.2.0/tutorial/disaster-recovery/ 23 | # Perform an In-Place Point-in-time-Recovery (PITR) 24 | restore: 25 | repoName: ~ # provide repo name 26 | enabled: false 27 | target: ~ # 2024-03-24 17:16:00-07 this is the target timestamp to go back to in current cluster 28 | instances: 29 | name: db # high availability 30 | replicas: 2 # 2 or 3 for high availability in TEST and PROD. 31 | metadata: 32 | annotations: 33 | prometheus.io/scrape: 'true' 34 | prometheus.io/port: '9187' 35 | dataVolumeClaimSpec: 36 | storage: 150Mi 37 | storageClassName: netapp-block-standard 38 | walStorage: 300Mi 39 | 40 | requests: 41 | cpu: 50m 42 | memory: 128Mi 43 | replicaCertCopy: 44 | requests: 45 | cpu: 1m 46 | memory: 32Mi 47 | 48 | pgBackRest: 49 | enabled: true 50 | backupPath: /backups/test/cluster/version # change it for PROD, create values-prod.yaml # this is only used in s3 backups context. 51 | 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. 52 | # If retention-full-type set to 'count' then the oldest backups will expire when the number of backups reach the number defined in retention 53 | # If retention-full-type set to 'time' then the number defined in retention will take that many days worth of full backups before expiration 54 | retentionFullType: count 55 | s3: 56 | enabled: false # if enabled, below must be provided 57 | retention: 7 # one weeks backup in object store. 58 | bucket: ~ 59 | endpoint: ~ 60 | accessKey: ~ 61 | secretKey: ~ 62 | fullBackupSchedule: ~ # make sure to provide values here, if s3 is enabled. 63 | incrementalBackupSchedule: ~ # make sure to provide values here, if s3 is enabled. 64 | pvc: 65 | retention: 1 # one day hot active backup in pvc 66 | retentionFullType: count 67 | fullBackupSchedule: 0 8 * * * 68 | incrementalBackupSchedule: 0 0-7,9-23 * * * # every hour incremental 69 | volume: 70 | accessModes: "ReadWriteOnce" 71 | storage: 100Mi 72 | storageClassName: netapp-file-backup 73 | 74 | config: 75 | requests: 76 | cpu: 5m 77 | memory: 32Mi 78 | repoHost: 79 | requests: 80 | cpu: 20m 81 | memory: 128Mi 82 | sidecars: 83 | requests: 84 | cpu: 5m 85 | memory: 16Mi 86 | jobs: 87 | requests: 88 | cpu: 20m 89 | memory: 128Mi 90 | 91 | patroni: 92 | postgresql: 93 | pg_hba: 94 | - "host all all 0.0.0.0/0 scram-sha-256" 95 | - "host all all ::1/128 scram-sha-256" 96 | parameters: 97 | shared_buffers: 16MB # default is 128MB; a good tuned default for shared_buffers is 25% of the memory allocated to the pod 98 | wal_buffers: "64kB" # this can be set to -1 to automatically set as 1/32 of shared_buffers or 64kB, whichever is larger 99 | min_wal_size: 32MB 100 | max_wal_size: 64MB # default is 1GB 101 | max_slot_wal_keep_size: 128MB # default is -1, allowing unlimited wal growth when replicas fall behind 102 | work_mem: 2MB # a work_mem value of 2 MB 103 | log_min_duration_statement: 1000ms # log queries taking more than 1 second to respond. 104 | 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. 105 | 106 | proxy: 107 | enabled: true 108 | pgBouncer: 109 | image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default 110 | replicas: 2 111 | requests: 112 | cpu: 5m 113 | memory: 32Mi 114 | maxConnections: 100 # make sure less than postgres max connections 115 | poolMode: 'transaction' 116 | 117 | # Postgres Cluster resource values: 118 | pgmonitor: 119 | enabled: true 120 | exporter: 121 | image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default 122 | requests: 123 | cpu: 10m 124 | memory: 32Mi 125 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Overview 4 | 5 | Act in the best interests of the community, your organization, and your fellow collaborators. We welcome and appreciate your contributions, in any capacity. 6 | 7 | > **Note for template users**: Please customize this section to reflect your organization's values and context. 8 | 9 | ## Our Pledge 10 | 11 | We as members, contributors, and leaders pledge to make participation in our 12 | community a harassment-free experience for everyone, regardless of age, body 13 | size, visible or invisible disability, ethnicity, sex characteristics, gender 14 | identity and expression, level of experience, education, socio-economic status, 15 | nationality, personal appearance, race, religion, or sexual identity 16 | and orientation. 17 | 18 | We pledge to act and interact in ways that contribute to an open, welcoming, 19 | diverse, inclusive, and healthy community. 20 | 21 | ## Our Standards 22 | 23 | Examples of behavior that contributes to a positive environment for our 24 | community include: 25 | 26 | * Demonstrating empathy and kindness toward other people 27 | * Being respectful of differing opinions, viewpoints, and experiences 28 | * Giving and gracefully accepting constructive feedback 29 | * Accepting responsibility and apologizing to those affected by our mistakes, 30 | and learning from the experience 31 | * Focusing on what is best not just for us as individuals, but for the 32 | overall community 33 | 34 | Examples of unacceptable behavior include: 35 | 36 | * The use of sexualized language or imagery, and sexual attention or 37 | advances of any kind 38 | * Trolling, insulting or derogatory comments, and personal or political attacks 39 | * Public or private harassment 40 | * Publishing others' private information, such as a physical or email 41 | address, without their explicit permission 42 | * Other conduct which could reasonably be considered inappropriate in a 43 | professional setting 44 | 45 | ## Enforcement Responsibilities 46 | 47 | Community leaders are responsible for clarifying and enforcing our standards of 48 | acceptable behavior and will take appropriate and fair corrective action in 49 | response to any behavior that they deem inappropriate, threatening, offensive, 50 | or harmful. 51 | 52 | Community leaders have the right and responsibility to remove, edit, or reject 53 | comments, commits, code, wiki edits, issues, and other contributions that are 54 | not aligned to this Code of Conduct, and will communicate reasons for moderation 55 | decisions when appropriate. 56 | 57 | ## Scope 58 | 59 | This Code of Conduct applies within all community spaces, and also applies when 60 | an individual is officially representing the community in public spaces. 61 | Examples of representing our community include using an official e-mail address, 62 | posting via an official social media account, or acting as an appointed 63 | representative at an online or offline event. 64 | 65 | ## Enforcement 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the community leaders responsible for enforcement. Please contact 69 | the repository maintainers through GitHub or by creating an issue in this 70 | repository. All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | > **Note for template users**: Please customize this section with your preferred 73 | > contact method for conduct reports (e.g., a dedicated email address, security 74 | > team contact, or other appropriate channel for your organization). 75 | 76 | All community leaders are obligated to respect the privacy and security of the 77 | reporter of any incident. 78 | 79 | ## Enforcement Guidelines 80 | 81 | Community leaders will follow these Community Impact Guidelines in determining 82 | the consequences for any action they deem in violation of this Code of Conduct: 83 | 84 | ### 1. Correction 85 | 86 | **Community Impact**: Use of inappropriate language or other behavior deemed 87 | unprofessional or unwelcome in the community. 88 | 89 | **Consequence**: A private, written warning from community leaders, providing 90 | clarity around the nature of the violation and an explanation of why the 91 | behavior was inappropriate. A public apology may be requested. 92 | 93 | ### 2. Warning 94 | 95 | **Community Impact**: A violation through a single incident or series 96 | of actions. 97 | 98 | **Consequence**: A warning with consequences for continued behavior. No 99 | interaction with the people involved, including unsolicited interaction with 100 | those enforcing the Code of Conduct, for a specified period of time. This 101 | includes avoiding interactions in community spaces as well as external channels 102 | like social media. Violating these terms may lead to a temporary or 103 | permanent ban. 104 | 105 | ### 3. Temporary Ban 106 | 107 | **Community Impact**: A serious violation of community standards, including 108 | sustained inappropriate behavior. 109 | 110 | **Consequence**: A temporary ban from any sort of interaction or public 111 | communication with the community for a specified period of time. No public or 112 | private interaction with the people involved, including unsolicited interaction 113 | with those enforcing the Code of Conduct, is allowed during this period. 114 | Violating these terms may lead to a permanent ban. 115 | 116 | ### 4. Permanent Ban 117 | 118 | **Community Impact**: Demonstrating a pattern of violation of community 119 | standards, including sustained inappropriate behavior, harassment of an 120 | individual, or aggression toward or disparagement of classes of individuals. 121 | 122 | **Consequence**: A permanent ban from any sort of public interaction within 123 | the community. 124 | 125 | ## Attribution 126 | 127 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 128 | version 2.0, available at 129 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 130 | 131 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 132 | enforcement ladder](https://github.com/mozilla/diversity). 133 | 134 | [homepage]: https://www.contributor-covenant.org 135 | 136 | For answers to common questions about this code of conduct, see the FAQ at 137 | https://www.contributor-covenant.org/faq. Translations are available at 138 | https://www.contributor-covenant.org/translations. 139 | -------------------------------------------------------------------------------- /backend/src/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing' 2 | import { Test } from '@nestjs/testing' 3 | import { UsersController } from './users.controller' 4 | import { UsersService } from './users.service' 5 | import request from 'supertest' 6 | import type { INestApplication } from '@nestjs/common' 7 | import { HttpException } from '@nestjs/common' 8 | import type { CreateUserDto } from './dto/create-user.dto' 9 | import type { UpdateUserDto } from './dto/update-user.dto' 10 | import type { UserDto } from './dto/user.dto' 11 | import { PrismaService } from 'src/prisma.service' 12 | describe('UserController', () => { 13 | let controller: UsersController 14 | let usersService: UsersService 15 | let app: INestApplication 16 | 17 | beforeEach(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | controllers: [UsersController], 20 | providers: [ 21 | UsersService, 22 | { 23 | provide: PrismaService, 24 | useValue: {}, 25 | }, 26 | ], 27 | }).compile() 28 | usersService = module.get(UsersService) 29 | controller = module.get(UsersController) 30 | app = module.createNestApplication() 31 | await app.init() 32 | }) 33 | // Close the app after each test 34 | afterEach(async () => { 35 | await app.close() 36 | }) 37 | 38 | it('should be defined', () => { 39 | expect(controller).toBeDefined() 40 | }) 41 | 42 | describe('create', () => { 43 | it('should call the service create method with the given dto and return the result', async () => { 44 | // Arrange 45 | const createUserDto: CreateUserDto = { 46 | email: 'test@example.com', 47 | name: 'Test User', 48 | } 49 | const expectedResult = { 50 | id: 1, 51 | ...createUserDto, 52 | } 53 | vi.spyOn(usersService, 'create').mockResolvedValue(expectedResult) 54 | 55 | // Act 56 | const result = await controller.create(createUserDto) 57 | 58 | // Assert 59 | expect(usersService.create).toHaveBeenCalledWith(createUserDto) 60 | expect(result).toEqual(expectedResult) 61 | }) 62 | }) 63 | describe('findAll', () => { 64 | it('should return an array of users', async () => { 65 | const result = [] 66 | result.push({ id: 1, name: 'Alice', email: 'test@gmail.com' }) 67 | vi.spyOn(usersService, 'findAll').mockResolvedValue(result) 68 | expect(await controller.findAll()).toBe(result) 69 | }) 70 | }) 71 | describe('findOne', () => { 72 | it('should return a user object', async () => { 73 | const result: UserDto = { 74 | id: 1, 75 | name: 'john', 76 | email: 'John_Doe@gmail.com', 77 | } 78 | vi.spyOn(usersService, 'findOne').mockResolvedValue(result) 79 | expect(await controller.findOne('1')).toBe(result) 80 | }) 81 | it('should throw error if user not found', async () => { 82 | vi.spyOn(usersService, 'findOne').mockResolvedValue(undefined) 83 | try { 84 | await controller.findOne('1') 85 | } catch (e) { 86 | expect(e).toBeInstanceOf(HttpException) 87 | expect(e.message).toBe('User not found.') 88 | } 89 | }) 90 | }) 91 | describe('update', () => { 92 | it('should update and return a user object', async () => { 93 | const id = '1' 94 | const updateUserDto: UpdateUserDto = { 95 | email: 'johndoe@example.com', 96 | name: 'John Doe', 97 | } 98 | const userDto: UserDto = { 99 | id: 1, 100 | name: 'John Doe', 101 | email: 'johndoe@example.com', 102 | } 103 | vi.spyOn(usersService, 'update').mockResolvedValue(userDto) 104 | 105 | expect(await controller.update(id, updateUserDto)).toBe(userDto) 106 | expect(usersService.update).toHaveBeenCalledWith(+id, updateUserDto) 107 | }) 108 | }) 109 | describe('remove', () => { 110 | it('should remove a user', async () => { 111 | const id = '1' 112 | vi.spyOn(usersService, 'remove').mockResolvedValue(undefined) 113 | 114 | expect(await controller.remove(id)).toBeUndefined() 115 | expect(usersService.remove).toHaveBeenCalledWith(+id) 116 | }) 117 | }) 118 | // Test the GET /users/search endpoint 119 | describe('GET /users/search', () => { 120 | // Test with valid query parameters 121 | it('given valid query parameters_should return an array of users with pagination metadata', async () => { 122 | // Mock the usersService.searchUsers method to return a sample result 123 | const result = { 124 | users: [ 125 | { id: 1, name: 'Alice', email: 'alice@example.com' }, 126 | { id: 2, name: 'Adam', email: 'Adam@example.com' }, 127 | ], 128 | page: 1, 129 | limit: 10, 130 | sort: '{"name":"ASC"}', 131 | filter: '[{"key":"name","operation":"like","value":"A"}]', 132 | total: 2, 133 | totalPages: 1, 134 | } 135 | vi.spyOn(usersService, 'searchUsers').mockImplementation(async () => result) 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 | -------------------------------------------------------------------------------- /.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 | db_triggers: 52 | description: Paths used to trigger a database deployment; e.g. ('charts/crunchy/') 53 | required: false 54 | type: string 55 | 56 | ### Usually a bad idea / not recommended 57 | timeout-minutes: 58 | description: "Timeout minutes" 59 | default: 10 60 | required: false 61 | type: number 62 | values: 63 | description: "Values file" 64 | default: "values.yaml" 65 | required: false 66 | type: string 67 | 68 | outputs: 69 | tag: 70 | description: "Which tag was used for deployment?" 71 | value: ${{ jobs.deploy.outputs.tag }} 72 | triggered: 73 | description: "Has a deployment has been triggered?" 74 | value: ${{ jobs.deploy.outputs.triggered }} 75 | 76 | secrets: 77 | oc_namespace: 78 | description: OpenShift namespace 79 | required: true 80 | oc_token: 81 | description: OpenShift token 82 | required: true 83 | 84 | permissions: {} 85 | 86 | jobs: 87 | deploy: 88 | name: Stack 89 | environment: ${{ inputs.environment }} 90 | runs-on: ubuntu-24.04 91 | outputs: 92 | tag: ${{ inputs.tag || steps.pr.outputs.pr }} 93 | triggered: ${{ steps.deploy.outputs.triggered }} 94 | steps: 95 | - uses: bcgov/action-crunchy@9b776dc20a55f435b7c5024152b6b7b294362809 # v1.2.5 96 | name: Deploy Crunchy 97 | id: deploy_crunchy 98 | with: 99 | oc_namespace: ${{ secrets.OC_NAMESPACE }} 100 | oc_token: ${{ secrets.OC_TOKEN }} 101 | environment: ${{ inputs.environment }} 102 | values_file: charts/crunchy/values.yml 103 | triggers: ${{ inputs.db_triggers }} 104 | 105 | # Variables 106 | - if: inputs.tag == '' 107 | id: pr 108 | uses: bcgov/action-get-pr@35514fa1d4765547da319e967b509363598e8b46 # v0.1.0 109 | 110 | - id: vars 111 | run: | 112 | # Vars: tag and release 113 | 114 | # Tag defaults to PR number, but can be overridden by inputs.tag 115 | tag=${{ inputs.tag || steps.pr.outputs.pr }} 116 | 117 | # Release name includes run numbers to ensure uniqueness 118 | release=${{ github.event.repository.name }}-${{ inputs.environment || steps.pr.outputs.pr || inputs.tag }} 119 | 120 | # version, to support helm packaging for non-pr based releases (workflow_dispatch). default to 1.0.0+github run number 121 | version=1.0.0+${{ github.run_number }} 122 | 123 | # Summary 124 | echo "tag=${tag}" 125 | echo "release=${release}" 126 | echo "version=${version}" 127 | 128 | # Output 129 | echo "tag=${tag}" >> $GITHUB_OUTPUT 130 | echo "release=${release}" >> $GITHUB_OUTPUT 131 | echo "version=${version}" >> $GITHUB_OUTPUT 132 | 133 | - name: Stop pre-existing deployments on PRs (status = pending-upgrade) 134 | if: github.event_name == 'pull_request' 135 | uses: bcgov/action-oc-runner@f900830adadd4d9eef3ca6ff80103e839ba8b7c0 # v1.3.0 136 | with: 137 | oc_namespace: ${{ secrets.oc_namespace }} 138 | oc_token: ${{ secrets.oc_token }} 139 | oc_server: ${{ vars.oc_server }} 140 | triggers: ${{ inputs.triggers }} 141 | commands: | 142 | # Interrupt any previous deployments (PR only) 143 | PREVIOUS=$(helm status ${{ steps.vars.outputs.release }} -o json | jq .info.status || true) 144 | if [[ ${PREVIOUS} =~ pending ]]; then 145 | echo "Rollback triggered" 146 | helm rollback ${{ steps.vars.outputs.release }} || \ 147 | helm uninstall ${{ steps.vars.outputs.release }} 148 | fi 149 | 150 | - uses: actions/checkout@v6 151 | - name: Debug Values File 152 | if: inputs.debug == 'true' 153 | run: ls -l charts/crunchy/values.yml 154 | 155 | - name: Helm Deploy 156 | id: deploy 157 | uses: bcgov/action-oc-runner@f900830adadd4d9eef3ca6ff80103e839ba8b7c0 # v1.3.0 158 | with: 159 | oc_namespace: ${{ secrets.oc_namespace }} 160 | oc_token: ${{ secrets.oc_token }} 161 | oc_server: ${{ vars.oc_server }} 162 | triggers: ${{ inputs.triggers }} 163 | ref: ${{ github.ref }} 164 | commands: | 165 | # Deploy 166 | 167 | # If directory provided, cd to it 168 | [ -z "${{ inputs.directory }}" ]|| cd ${{ inputs.directory }} 169 | 170 | # Helm package 171 | sed -i 's/^name:.*/name: ${{ github.event.repository.name }}/' Chart.yaml 172 | helm package -u . --app-version="tag-${{ steps.vars.outputs.tag }}_run-${{ github.run_number }}" --version=${{ steps.pr.outputs.pr || steps.vars.outputs.version }} 173 | # print the values.yaml file to see the values being used 174 | # Helm upgrade/rollout 175 | helm upgrade \ 176 | --set-string global.repository=${{ github.repository }} \ 177 | --set-string global.tag="${{ steps.vars.outputs.tag }}" \ 178 | --set-string global.config.databaseUser="${{ inputs.db_user }}" \ 179 | --set-string global.databaseAlias="${{ steps.deploy_crunchy.outputs.release }}-crunchy" \ 180 | ${{ inputs.params }} \ 181 | --install --wait ${{ inputs.atomic && '--atomic' || '' }} ${{ steps.vars.outputs.release }} \ 182 | --timeout ${{ inputs.timeout-minutes }}m \ 183 | --values ${{ inputs.values }} \ 184 | ./${{ github.event.repository.name }}-${{ steps.pr.outputs.pr || steps.vars.outputs.version }}.tgz 185 | 186 | # Helm release history 187 | helm history ${{ steps.vars.outputs.release }} 188 | 189 | # Completed pod cleanup 190 | oc delete po --field-selector=status.phase==Succeeded || true 191 | -------------------------------------------------------------------------------- /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 '../../generated/prisma/client.js' 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(expectedOutput) 237 | }) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /.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 | 178 | # VSCode/Cursor workspace settings (allow settings.json and extensions.json for team consistency) 179 | .vscode/* 180 | !.vscode/settings.json 181 | !.vscode/extensions.json 182 | 183 | # yarn v2 184 | .yarn/cache 185 | .yarn/unplugged 186 | .yarn/build-state.yml 187 | .yarn/install-state.gz 188 | .pnp.* 189 | 190 | ### Node Patch ### 191 | # Serverless Webpack directories 192 | .webpack/ 193 | 194 | # Optional stylelint cache 195 | 196 | # SvelteKit build / generate output 197 | .svelte-kit 198 | 199 | ### Python ### 200 | # Byte-compiled / optimized / DLL files 201 | __pycache__/ 202 | *.py[cod] 203 | *$py.class 204 | 205 | # C extensions 206 | 207 | # Distribution / packaging 208 | .Python 209 | build/ 210 | develop-eggs/ 211 | dist/ 212 | downloads/ 213 | eggs/ 214 | .eggs/ 215 | lib/ 216 | lib64/ 217 | parts/ 218 | sdist/ 219 | var/ 220 | wheels/ 221 | share/python-wheels/ 222 | *.egg-info/ 223 | .installed.cfg 224 | *.egg 225 | MANIFEST 226 | 227 | # PyInstaller 228 | # Usually these files are written by a python script from a template 229 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 230 | *.manifest 231 | *.spec 232 | 233 | # Installer logs 234 | pip-log.txt 235 | pip-delete-this-directory.txt 236 | 237 | # Unit test / coverage reports 238 | htmlcov/ 239 | .tox/ 240 | .nox/ 241 | .coverage 242 | .coverage.* 243 | nosetests.xml 244 | coverage.xml 245 | *.cover 246 | *.py,cover 247 | .hypothesis/ 248 | .pytest_cache/ 249 | cover/ 250 | 251 | # Translations 252 | *.mo 253 | *.pot 254 | 255 | # Django stuff: 256 | local_settings.py 257 | db.sqlite3 258 | db.sqlite3-journal 259 | 260 | # Flask stuff: 261 | instance/ 262 | .webassets-cache 263 | 264 | # Scrapy stuff: 265 | .scrapy 266 | 267 | # Sphinx documentation 268 | docs/_build/ 269 | 270 | # PyBuilder 271 | .pybuilder/ 272 | target/ 273 | 274 | # Jupyter Notebook 275 | .ipynb_checkpoints 276 | 277 | # IPython 278 | profile_default/ 279 | ipython_config.py 280 | 281 | # pyenv 282 | # For a library or package, you might want to ignore these files since the code is 283 | # intended to run in multiple environments; otherwise, check them in: 284 | # .python-version 285 | 286 | # pipenv 287 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 288 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 289 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 290 | # install all needed dependencies. 291 | #Pipfile.lock 292 | 293 | # poetry 294 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 295 | # This is especially recommended for binary packages to ensure reproducibility, and is more 296 | # commonly ignored for libraries. 297 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 298 | #poetry.lock 299 | 300 | # pdm 301 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 302 | #pdm.lock 303 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 304 | # in version control. 305 | # https://pdm.fming.dev/#use-with-ide 306 | .pdm.toml 307 | 308 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 309 | __pypackages__/ 310 | 311 | # Celery stuff 312 | celerybeat-schedule 313 | celerybeat.pid 314 | 315 | # SageMath parsed files 316 | *.sage.py 317 | 318 | # Environments 319 | .venv 320 | env/ 321 | venv/ 322 | ENV/ 323 | env.bak/ 324 | venv.bak/ 325 | 326 | # Spyder project settings 327 | .spyderproject 328 | .spyproject 329 | 330 | # Rope project settings 331 | .ropeproject 332 | 333 | # mkdocs documentation 334 | /site 335 | 336 | # mypy 337 | .mypy_cache/ 338 | .dmypy.json 339 | dmypy.json 340 | 341 | # Pyre type checker 342 | .pyre/ 343 | 344 | # pytype static type analyzer 345 | .pytype/ 346 | 347 | # Cython debug symbols 348 | cython_debug/ 349 | 350 | # PyCharm 351 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 352 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 353 | # and can be added to the global gitignore or merged into this file. For a more nuclear 354 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 355 | #.idea/ 356 | 357 | ### Python Patch ### 358 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 359 | poetry.toml 360 | 361 | # ruff 362 | .ruff_cache/ 363 | 364 | # LSP config files 365 | pyrightconfig.json 366 | 367 | # End of https://www.toptal.com/developers/gitignore/api/node,java,python,go 368 | .idea 369 | *.key 370 | *.pem 371 | *.pub 372 | 373 | # IDE 374 | .codebuddy 375 | 376 | # Specs directory (symlinked to external location, excluded from main branch) 377 | # The symlink points to ~/Documents/4-Specs/quickstart-openshift 378 | # See specs/README.md for setup instructions if needed 379 | specs 380 | 381 | # Prisma 7 generated client 382 | backend/generated/ 383 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------