├── 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 | | Employee ID |
69 | Employee Name |
70 | Employee Email |
71 | |
72 |
73 |
74 |
75 | {data.map((user: UserDto) => (
76 |
77 | | {user.id} |
78 | {user.name} |
79 | {user.email} |
80 |
81 |
84 | |
85 |
86 | ))}
87 |
88 |
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 |
--------------------------------------------------------------------------------