Compare commits

..

3 Commits

Author SHA1 Message Date
burkkyy cef6b487cd basic project files 2026-06-19 23:47:07 -07:00
burkkyy e3e520f7a9 api end fixes 2026-06-19 23:45:56 -07:00
burkkyy 84f894c356 templating api 2026-06-19 22:20:43 -07:00
124 changed files with 14847 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
api/node_modules
api/dist
web/node_modules
web/dist
dist
npm-debug.log
node_modules
docs
/now.*
/*.now.*
**/now.*
+11
View File
@@ -0,0 +1,11 @@
env:
browser: true
es2021: true
node: true
extends:
- prettier
overrides: []
parserOptions:
ecmaVersion: latest
sourceType: module
rules: {}
+112
View File
@@ -0,0 +1,112 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories + files
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
app
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
db/sapassword.env
.env.development
# DB Data
db/data
/db_dumps
+19
View File
@@ -0,0 +1,19 @@
# .prettierrc or .prettierrc.yaml
$schema: "https://json.schemastore.org/prettierrc"
embeddedLanguageFormatting: "auto"
trailingComma: "es5"
tabWidth: 2
semi: false
singleQuote: false
singleAttributePerLine: true
useTabs: false
endOfLine: "auto"
printWidth: 100
plugins:
- prettier-plugin-embed
- prettier-plugin-sql
overrides:
- files: "*.sql"
options:
language: "tsql"
paramTypes: "{ named: [':'] }"
+2
View File
@@ -0,0 +1,2 @@
nodejs 20.10.0
ruby 3.2.2
+22
View File
@@ -0,0 +1,22 @@
{
"eslint.workingDirectories": ["./api", "./web"],
"eslint.validate": ["javascript", "typescript", "vue"],
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"prettier.configPath": ".prettierrc.yaml",
"prettier.requireConfig": true,
"prettier.resolveGlobalModules": true,
"js/ts.preferences.importModuleSpecifier": "non-relative",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
+77
View File
@@ -0,0 +1,77 @@
# Stage 0 - base node customizations
FROM node:20.19.4-alpine3.22 AS base-node
RUN npm install -g npm@10.2.5
# Stage 1 - api build - requires development environment because typescript
FROM base-node AS api-build-stage
ENV NODE_ENV=development
WORKDIR /usr/src/api
COPY api/package*.json ./
COPY api/tsconfig*.json ./
RUN npm install
COPY api ./
RUN npm run build
# Copy html files, remove once we are using Vite for build process
COPY api/src/templates ./dist/templates
# Stage 2 - web build - requires development environment to install vue-cli-service
FROM base-node AS web-build-stage
ENV NODE_ENV=development
WORKDIR /usr/src/web
COPY web/package*.json ./
COPY web/tsconfig*.json ./
COPY web/vite.config.js ./
RUN npm install
COPY web ./
# Switching to production mode for build environment.
ENV NODE_ENV=production
RUN npm run build
# Stage 3 - production setup
FROM base-node
ARG RELEASE_TAG
ARG GIT_COMMIT_HASH
ENV RELEASE_TAG=${RELEASE_TAG}
ENV GIT_COMMIT_HASH=${GIT_COMMIT_HASH}
# Persists TZ=UTC effect after container build and into container run
# Ensures dates/times are consistently formated as UTC
# Conversion to local time should happen in the UI
ENV TZ=UTC
ENV NODE_ENV=production
USER node
WORKDIR /home/node/app
RUN chown -R node:node /home/node/app
COPY --from=api-build-stage --chown=node:node /usr/src/api/package*.json ./
RUN npm install && npm cache clean --force --loglevel=error
COPY --from=api-build-stage --chown=node:node /usr/src/api/dist ./dist/
COPY --from=web-build-stage --chown=node:node /usr/src/web/dist ./dist/web/
RUN echo "RELEASE_TAG=${RELEASE_TAG}" >> VERSION
RUN echo "GIT_COMMIT_HASH=${GIT_COMMIT_HASH}" >> VERSION
EXPOSE 3000
COPY --from=api-build-stage --chown=node:node /usr/src/api/bin/boot-app.sh ./bin/
RUN chmod +x ./bin/boot-app.sh
CMD ["./bin/boot-app.sh"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+28
View File
@@ -0,0 +1,28 @@
/* eslint-env node */
// https://github.com/typescript-eslint/typescript-eslint/issues/251
module.exports = {
root: true,
env: {
es2020: true,
node: true,
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
overrides: [],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
}
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
if [ "$RUN_SCHEDULER" = "true" ]; then
echo "Running scheduler"
if [ "$NODE_ENV" = "production" ]; then
node ./dist/scheduler.js
else
npm run start:scheduler
fi
exit 0
fi
if [ "$NODE_ENV" = "production" ]; then
node ./dist/initializers/index.js
node ./dist/server.js
else
npm run initializers
npm run start
fi
+14
View File
@@ -0,0 +1,14 @@
FROM node:24.15.0-alpine3.23
RUN npm i -g npm@11.14.1
WORKDIR /usr/src/api
COPY package*.json ./
RUN npm clean-install
COPY bin/boot-app.sh ./bin/
RUN chmod +x ./bin/boot-app.sh
CMD ["/usr/src/api/bin/boot-app.sh"]
+8134
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
{
"name": "alphane-api",
"private": true,
"version": "0.0.1",
"description": "Alphane Backend",
"main": "src/server.js",
"scripts": {
"build": "tsc && tsc-alias",
"clean": "rm -rf ./dist",
"start": "ts-node-dev -r tsconfig-paths/register src/server.ts",
"start:scheduler": "ts-node-dev -r tsconfig-paths/register src/scheduler.ts",
"ts-node": "ts-node -r tsconfig-paths/register --logError",
"knex": "ts-node -r tsconfig-paths/register ./node_modules/.bin/knex --knexfile src/config.d/knexfile.ts",
"initializers": "ts-node -r tsconfig-paths/register --files src/initializers/index.ts",
"test": "vitest",
"lint": "eslint . --ext .js,.ts --ignore-path ../.gitignore",
"check-types": "tsc --noEmit"
},
"author": "Caleb Burke",
"license": "ISC",
"dependencies": {
"@fontsource/audiowide": "^5.2.7",
"@sequelize/core": "^7.0.0-alpha.40",
"@sequelize/postgres": "^7.0.0-alpha.46",
"axios": "^1.7.3",
"cors": "^2.8.5",
"cron": "^4.4.0",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-form-data": "^3.0.1",
"express-jwt": "^8.4.1",
"geist": "^1.7.0",
"helmet": "^8.1.0",
"jwks-rsa": "^3.1.0",
"knex": "^3.1.0",
"lodash": "^4.17.21",
"luxon": "^3.5.0",
"morgan": "^1.10.0",
"nodemailer": "^7.0.5",
"papaparse": "^5.4.1",
"redis": "^5.8.2",
"reflect-metadata": "^0.2.2",
"winston": "^3.17.0"
},
"devDependencies": {
"@faker-js/faker": "^9.9.0",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.3",
"@types/express-form-data": "^2.0.5",
"@types/lodash": "^4.17.13",
"@types/luxon": "^3.4.2",
"@types/morgan": "^1.9.9",
"@types/node-schedule": "^2.1.5",
"@types/nodemailer": "^7.0.1",
"@types/papaparse": "^5.3.15",
"@types/sharp": "^0.31.1",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.40.0",
"@typescript-eslint/parser": "^8.40.0",
"axios-mock-adapter": "^2.1.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"fishery": "^2.2.2",
"prettier": "^3.1.1",
"supertest": "^7.1.4",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsc-alias": "^1.8.8",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
}
+60
View File
@@ -0,0 +1,60 @@
import express, { type Request, type Response } from "express"
import cors from "cors"
import path from "path"
import helmet from "helmet"
import formData from "express-form-data"
import { AUTH0_DOMAIN, FRONTEND_URL } from "@/config"
import { requestLoggerMiddleware } from "@/middlewares"
import router from "@/router"
import enhancedQsDecoder from "@/utils/enhanced-qs-decoder"
export const app = express()
app.set("query parser", enhancedQsDecoder)
app.use(express.json({ limit: "40mb" })) // for parsing application/json
app.use(express.urlencoded({ extended: true, limit: "40mb" })) // for parsing application/x-www-form-urlencoded
app.use(formData.parse({ autoClean: true, maxFieldsSize: 40 * 1024 * 1024 })) // for parsing multipart/form-data
app.use(formData.union()) // for parsing multipart/form-data
app.use(
helmet.contentSecurityPolicy({
directives: {
"default-src": ["'self'", FRONTEND_URL, AUTH0_DOMAIN],
"base-uri": ["'self'"],
"block-all-mixed-content": [],
"font-src": ["'self'", "https:", "data:"],
"frame-ancestors": ["'self'"],
"img-src": ["'self'", "data:", "https:"],
"object-src": ["'none'"],
"script-src": ["'self'", "'unsafe-eval'"],
"script-src-attr": ["'none'"],
"style-src": ["'self'", "https:", "'unsafe-inline'"],
"worker-src": ["'self'", "blob:"],
"frame-src": ["'self'", "blob:", "data:", FRONTEND_URL, AUTH0_DOMAIN],
"connect-src": ["'self'", FRONTEND_URL, AUTH0_DOMAIN],
},
})
)
// very basic CORS setup
app.use(
cors({
origin: FRONTEND_URL,
optionsSuccessStatus: 200,
credentials: true,
})
)
app.use(requestLoggerMiddleware)
app.use(router)
// serves the static files generated by the front-end
app.use(express.static(path.join(__dirname, "web")))
// if no other routes match, just send the front-end
app.use((_req: Request, res: Response) => {
res.sendFile(path.join(__dirname, "web/index.html"))
})
export default app
+18
View File
@@ -0,0 +1,18 @@
import { Knex } from "knex"
import dbMigrationClient from "@/db/db-migration-client"
// Hoists config from db client
const config: { [key: string]: Knex.Config } = {
development: {
...dbMigrationClient.client.config,
},
test: {
...dbMigrationClient.client.config,
},
production: {
...dbMigrationClient.client.config,
},
}
export default config
+68
View File
@@ -0,0 +1,68 @@
import path from "path"
import * as dotenv from "dotenv"
import { stripTrailingSlash } from "@/utils/strip-trailing-slash"
export const NODE_ENV = process.env.NODE_ENV || "development"
let dotEnvPath
switch (process.env.NODE_ENV) {
case "test":
dotEnvPath = path.resolve(__dirname, "../.env.test")
break
case "production":
dotEnvPath = path.resolve(__dirname, "../.env.production")
break
default:
dotEnvPath = path.resolve(__dirname, "../.env.development")
}
dotenv.config({ path: dotEnvPath })
if (process.env.NODE_ENV !== "test") {
console.log("Loading env: ", dotEnvPath)
}
export const API_PORT = process.env.API_PORT || "3000"
export const JOB_PORT = process.env.JOB_PORT || "3001"
export const FRONTEND_URL = process.env.FRONTEND_URL || ""
export const AUTH0_DOMAIN = stripTrailingSlash(process.env.VITE_AUTH0_DOMAIN || "")
export const AUTH0_AUDIENCE = process.env.VITE_AUTH0_AUDIENCE
export const AUTH0_REDIRECT = process.env.VITE_AUTH0_REDIRECT || process.env.FRONTEND_URL || ""
export const APPLICATION_NAME = process.env.VITE_APPLICATION_NAME || ""
export const DB_HOST = process.env.DB_HOST || ""
export const DB_USERNAME = process.env.DB_USERNAME || ""
export const DB_PASSWORD = process.env.DB_PASSWORD || ""
export const DB_DATABASE = process.env.DB_DATABASE || ""
export const DB_PORT = parseInt(process.env.DB_PORT || "1433")
export const DB_TRUST_SERVER_CERTIFICATE = process.env.DB_TRUST_SERVER_CERTIFICATE === "true"
export const REDIS_CONNECTION_URL = process.env.REDIS_CONNECTION_URL || ""
export const DB_HEALTH_CHECK_INTERVAL_SECONDS = parseInt(
process.env.DB_HEALTH_CHECK_INTERVAL_SECONDS || "5"
)
export const DB_HEALTH_CHECK_TIMEOUT_SECONDS = parseInt(
process.env.DB_HEALTH_CHECK_TIMEOUT_SECONDS || "10"
)
export const DB_HEALTH_CHECK_RETRIES = parseInt(process.env.DB_HEALTH_CHECK_RETRIES || "3")
export const DB_HEALTH_CHECK_START_PERIOD_SECONDS = parseInt(
process.env.DB_HEALTH_CHECK_START_PERIOD_SECONDS || "5"
)
export const SEQUELIZE_LOGGING = process.env.SEQUELIZE_LOGGING === "true"
export const RELEASE_TAG = process.env.RELEASE_TAG || ""
export const GIT_COMMIT_HASH = process.env.GIT_COMMIT_HASH || ""
export const RUN_SCHEDULER = process.env.RUN_SCHEDULER || "false"
export const DEFAULT_LOG_LEVEL = process.env.DEFAULT_LOG_LEVEL || "debug"
// Internal Helpers
export const APP_ROOT_PATH = path.resolve(__dirname, "..")
export const SOURCE_ROOT_PATH =
NODE_ENV === "production" ? path.join(APP_ROOT_PATH, "dist") : path.join(APP_ROOT_PATH, "src")
+99
View File
@@ -0,0 +1,99 @@
# Controllers
These files map api routes to models, policies, services, and serializers.
See https://guides.rubyonrails.org/routing.html#crud-verbs-and-actions
e.g.
```typescript
router.route("/api/users").post(UsersController.create)
```
maps the `/api/users` POST endpoint to the `UsersController#create` instance method.
Controllers are advantageous because they provide a suite of helper methods to access various request methods. .e.g. `currentUser`, or `params`. They also provide a location to perform policy checks.
Controllers should implement the BaseController, and provide instance methods.
The `BaseController` provides the magic that lets those methods map to an appropriate route.
## Namespacing
If you need an action that syncs a user with an external directory, a POST route `/api/users/:userId/directory-sync` is the best way to avoid future conflicts and refactors. To implement this you need to "namespace/modularize" the controller. Generally speaking, it is more flexible to keep all routes as CRUD actions, and nest controllers as needed, than it is to add custom routes to a given controller.
e.g. `Users.DirectorySyncController.create` is preferred to `UsersController#directorySync`. Once you start using non-CRUD actions, your controllers will quickly expand beyond human readability and comprehension. Opting to use PascalCase for namespaces as that is the best way to avoid conflicts with local variables.
This is how you would create a namespaced controller:
```bash
api/
|-- src/
| |-- controllers/
| |-- users/
| |-- directory-sync-controller.ts
| |-- index.ts
| |-- users-controller.ts
```
```typescript
// api/src/controllers/users/users-controller.ts
import { User } from "@/models"
import BaseController from "@/base-controller"
export class UsersController extends BaseController<User> {
static async create() {
// Perform user creation
// Perform policy check
// Call appropriate service
// Perform serialization if needed
// Return response
}
}
```
```typescript
// api/src/controllers/users/directory-sync-controller.ts
import { User } from "@/models"
import BaseController from "@/base-controller"
export class DirectorySyncController extends BaseController<User> {
static async create() {
// Perform user lookup
// Perform policy check
// Call appropriate service
// Perform serialization if needed
// Return response
}
}
```
```typescript
// api/src/controllers/users/index.ts
export { DirectorySyncController } from "./directory-sync-controller"
```
```typescript
// api/src/controllers/index.ts
import * as Users from "./users"
import { UsersController } from "./users-controller"
export { Users, UsersController }
```
```typescript
// api/src/routes.ts
import { Router } from "express"
import { Users } from "@/controllers"
const router = Router()
router.route("/api/users").get(UsersController.index).post(UsersController.create)
router
.route("/api/users/:userId")
.get(UsersController.show)
.put(UsersController.update)
.delete(UsersController.destroy)
router.route("/api/users/:userId/directory-sync").post(Users.DirectorySyncController.create)
```
+196
View File
@@ -0,0 +1,196 @@
import { NextFunction, Request, Response } from "express"
import { Attributes, Model, Order, WhereOptions } from "@sequelize/core"
import { dropRight, isEmpty, isNil, uniqBy } from "lodash"
import User from "@/models/user"
import { type BaseScopeOptions } from "@/policies"
export type Actions = "index" | "show" | "new" | "edit" | "create" | "update" | "destroy"
type ControllerRequest = Request & {
currentUser: User
}
/** Keep in sync with web/src/api/base-api.ts */
export type ModelOrder = Order &
(
| [string, string]
| [string, string, string]
| [string, string, string, string]
| [string, string, string, string, string]
| [string, string, string, string, string, string]
)
// Keep in sync with web/src/api/base-api.ts
const MAX_PER_PAGE = 1000
const MAX_PER_PAGE_EQUIVALENT = -1
const DEFAULT_PER_PAGE = 10
// See https://guides.rubyonrails.org/routing.html#crud-verbs-and-actions
export class BaseController<TModel extends Model = never> {
protected request: ControllerRequest
protected response: Response
protected next: NextFunction
cacheIndex = false
cacheShow = false
cacheDuration = 0
cachePrefix = ""
cacheDependents = new Array<string>()
constructor(req: Request, res: Response, next: NextFunction) {
// Assumes authorization has occured first in
// api/src/middlewares/jwt-middleware.ts and api/src/middlewares/authorization-middleware.ts
// At some future point it would make sense to do all that logic as
// controller actions to avoid the need for hack
this.request = req as ControllerRequest
this.response = res as Response
this.next = next
}
static get index() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.index().catch(next)
}
}
// Usage app.post("/api/users", UsersController.create)
// maps /api/users to UsersController#create()
static get create() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.create().catch(next)
}
}
static get show() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.show().catch(next)
}
}
static get update() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.update().catch(next)
}
}
static get destroy() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.destroy().catch(next)
}
}
index(): Promise<unknown> {
throw new Error("Not Implemented")
}
create(): Promise<unknown> {
throw new Error("Not Implemented")
}
show(): Promise<unknown> {
throw new Error("Not Implemented")
}
update(): Promise<unknown> {
throw new Error("Not Implemented")
}
destroy(): Promise<unknown> {
throw new Error("Not Implemented")
}
// Internal helpers
// This should have been loaded in the authorization middleware
// Currently assuming that authorization happens before this controller gets called.
// Child controllers that are on an non-authorizable route should override this method
// and return undefined
get currentUser(): User {
return this.request.currentUser
}
get params() {
return this.request.params
}
get query() {
return this.request.query
}
get pagination() {
const page = parseInt(this.query.page?.toString() || "") || 1
const perPage = parseInt(this.query.perPage?.toString() || "") || DEFAULT_PER_PAGE
const limit = this.determineLimit(perPage)
const offset = (page - 1) * limit
return {
page,
perPage,
limit,
offset,
}
}
buildWhere<TModelOverride extends Model = TModel>(
overridableOptions: WhereOptions<Attributes<TModelOverride>> = {},
nonOverridableOptions: WhereOptions<Attributes<TModelOverride>> = {}
): WhereOptions<Attributes<TModelOverride>> {
// TODO: consider if we should add parsing of Sequelize [Op.is] and [Op.not] here
// or in the api/src/utils/enhanced-qs-decoder.ts function
const queryWhere = this.query.where as WhereOptions<Attributes<TModelOverride>>
return {
...overridableOptions,
...queryWhere,
...nonOverridableOptions,
} as WhereOptions<Attributes<TModelOverride>>
}
buildFilterScopes<FilterOptions extends Record<string, unknown>>(
initialScopes: BaseScopeOptions[] = [],
filtersOverride?: FilterOptions
): BaseScopeOptions[] {
const filters = filtersOverride ?? (this.query.filters as FilterOptions)
const scopes = initialScopes
if (!isEmpty(filters)) {
Object.entries(filters).forEach(([key, value]) => {
scopes.push({ method: [key, value] })
})
}
return scopes
}
buildOrder(
overridableOrder: ModelOrder[] = [],
nonOverridableOrder: ModelOrder[] = []
): ModelOrder[] | undefined {
const orderQuery = this.query.order as unknown as ModelOrder[] | undefined
if (isNil(orderQuery)) {
return [...nonOverridableOrder, ...overridableOrder]
}
const order = [...nonOverridableOrder, ...orderQuery, ...overridableOrder]
const uniqueOrder = uniqBy(order, (order) => {
const orderExcludingDirection = dropRight(order)
return orderExcludingDirection.join(".").toLowerCase()
})
return uniqueOrder
}
private determineLimit(perPage: number) {
if (perPage === MAX_PER_PAGE_EQUIVALENT) {
return MAX_PER_PAGE
}
return Math.max(1, Math.min(perPage, MAX_PER_PAGE))
}
}
export default BaseController
@@ -0,0 +1,31 @@
import { User } from "@/models"
import { UsersPolicy } from "@/policies"
import { ShowSerializer } from "@/serializers/current-user"
import BaseController from "@/controllers/base-controller"
export class CurrentUserController extends BaseController {
async show() {
try {
const user = this.currentUser
const policy = this.buildPolicy(user)
if (!policy.show()) {
return this.response.status(403).json({
message: "You are not authorized to view the current user",
})
}
const serializedUser = ShowSerializer.perform(user)
return this.response.json({ user: serializedUser, policy })
} catch (error) {
return this.response.status(400).json({
message: `Error fetching current user: ${error}`,
})
}
}
private buildPolicy(user: User) {
return new UsersPolicy(this.currentUser, user)
}
}
export default CurrentUserController
+3
View File
@@ -0,0 +1,3 @@
// Controllers
export { CurrentUserController } from "./current-user-controller"
export { UsersController } from "./users-controller"
+146
View File
@@ -0,0 +1,146 @@
import { isNil } from "lodash"
import logger from "@/utils/logger"
import { User } from "@/models"
import { UsersPolicy } from "@/policies"
import { CreateService, DestroyService, UpdateService } from "@/services/users"
import { IndexSerializer, ShowSerializer } from "@/serializers/users"
import BaseController from "@/controllers/base-controller"
export class UsersController extends BaseController<User> {
async index() {
try {
const where = this.buildWhere()
const scopes = this.buildFilterScopes()
const scopedUsers = UsersPolicy.applyScope(scopes, this.currentUser)
const totalCount = await scopedUsers.count({ where })
const users = await scopedUsers.findAll({
where,
limit: this.pagination.limit,
offset: this.pagination.offset,
order: this.buildOrder(),
})
const serializedUsers = IndexSerializer.perform(users)
return this.response.json({
users: serializedUsers,
totalCount,
})
} catch (error) {
logger.error("Error fetching users" + error)
return this.response.status(400).json({
message: `Error fetching users: ${error}`,
})
}
}
async show() {
try {
const user = await this.loadUser()
if (isNil(user)) {
return this.response.status(404).json({
message: "User not found",
})
}
const policy = this.buildPolicy(user)
if (!policy.show()) {
return this.response.status(403).json({
message: "You are not authorized to view this user",
})
}
const serializedUser = ShowSerializer.perform(user)
return this.response.json({ user: serializedUser, policy })
} catch (error) {
logger.error("Error fetching user" + error)
return this.response.status(400).json({
message: `Error fetching user: ${error}`,
})
}
}
async create() {
try {
const policy = this.buildPolicy()
if (!policy.create()) {
return this.response.status(403).json({
message: "You are not authorized to create users",
})
}
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
const user = await CreateService.perform(permittedAttributes)
const serializedUser = ShowSerializer.perform(user)
return this.response.status(201).json({ user: serializedUser })
} catch (error) {
logger.error("Error creating user" + error)
return this.response.status(422).json({
message: `Error creating user: ${error}`,
})
}
}
async update() {
try {
const user = await this.loadUser()
if (isNil(user)) {
return this.response.status(404).json({
message: "User not found",
})
}
const policy = this.buildPolicy(user)
if (!policy.update()) {
return this.response.status(403).json({
message: "You are not authorized to update this user",
})
}
const permittedAttributes = policy.permitAttributes(this.request.body)
const updatedUser = await UpdateService.perform(user, permittedAttributes)
const serializedUser = ShowSerializer.perform(updatedUser)
return this.response.json({ user: serializedUser })
} catch (error) {
logger.error("Error updating user" + error)
return this.response.status(422).json({
message: `Error updating user: ${error}`,
})
}
}
async destroy() {
try {
const user = await this.loadUser()
if (isNil(user)) {
return this.response.status(404).json({
message: "User not found",
})
}
const policy = this.buildPolicy(user)
if (!policy.destroy()) {
return this.response.status(403).json({
message: "You are not authorized to delete this user",
})
}
await DestroyService.perform(user)
return this.response.status(204).send()
} catch (error) {
logger.error("Error deleting user" + error)
return this.response.status(422).json({
message: `Error deleting user: ${error}`,
})
}
}
private async loadUser() {
return User.findByPk(this.params.userId)
}
private buildPolicy(user: User = User.build()) {
return new UsersPolicy(this.currentUser, user)
}
}
export default UsersController
+94
View File
@@ -0,0 +1,94 @@
import { REDIS_CONNECTION_URL } from "@/config"
import { logger } from "@/utils/logger"
import { RedisClientType, createClient } from "@redis/client"
import { ScanOptions } from "@redis/client/dist/lib/commands/SCAN"
class CacheClient {
protected client: RedisClientType
protected failures: number = 0
constructor() {
logger.info(`INIT CACHE: ${REDIS_CONNECTION_URL}`)
this.client = createClient({ url: REDIS_CONNECTION_URL })
this.client.on("error", (err) => {
this.onError(err)
})
this.client.on("connect", async () => {
this.failures = 0
await this.setValueNoExpire("TESTING", 123)
logger.info("Redis Client Connect")
})
}
// eslint-disable-next-line
onError(err: any) {
if (this.failures < 5) {
this.failures++
logger.error(`Redis Connection Error ${this.failures}: ${err.message}`)
} else if (this.failures == 5) {
this.failures++
logger.error("Giving up on Redis")
} else {
logger.error(`Redis Generic Error: ${err.message}`)
}
}
async getClient() {
if (!this.client.isOpen) await this.client.connect()
return this
}
// eslint-disable-next-line
async setValue(key: string, value: any, expireSeconds = 90) {
if (expireSeconds <= 0) this.setValueNoExpire(key, value)
else this.client.set(key, value, { EX: expireSeconds })
}
// eslint-disable-next-line
async setValueNoExpire(key: string, value: any) {
this.client.set(key, value)
}
async getValue(key: string) {
return this.client.get(key)
}
async deleteValue(key: string) {
return this.client.del(key)
}
async deleteValuesByPattern(pattern: string) {
const scanCommand = { MATCH: `${pattern}*` } as ScanOptions
let cursor = "0"
do {
const reply = await this.client.scan(cursor, scanCommand)
cursor = reply.cursor
const keys = reply.keys
if (keys.length > 0) {
await this.client.del(keys)
}
} while (cursor !== "0")
}
async getKeysByPattern(pattern: string): Promise<string[]> {
const scanCommand = { MATCH: `${pattern}*` } as ScanOptions
let cursor = "0"
let matches = new Array<string>()
do {
const reply = await this.client.scan(cursor, scanCommand)
cursor = reply.cursor
const keys = reply.keys
if (keys.length > 0) {
matches = matches.concat(keys)
}
} while (cursor !== "0")
return matches
}
}
const cache = new CacheClient()
export default cache
+4
View File
@@ -0,0 +1,4 @@
# DB Data
This folder contains SQL scripts or JSON files that contain data for seeding the database.
Most of these things will be converted into seeds and factories, but they live here in the meantime.
+53
View File
@@ -0,0 +1,53 @@
import { Sequelize, Options } from "@sequelize/core"
import { PostgresDialect } from "@sequelize/postgres"
import { isEmpty, isNil } from "lodash"
import {
DB_DATABASE,
DB_HOST,
DB_PASSWORD,
DB_PORT,
DB_USERNAME,
NODE_ENV,
SEQUELIZE_LOGGING,
} from "@/config"
import compactSql from "@/utils/compact-sql"
if (isEmpty(DB_DATABASE)) throw new Error("database name is unset.")
if (isEmpty(DB_USERNAME)) throw new Error("database username is unset.")
if (isEmpty(DB_PASSWORD)) throw new Error("database password is unset.")
if (isEmpty(DB_HOST)) throw new Error("database host is unset.")
if (isNil(DB_PORT) || isNaN(DB_PORT)) throw new Error("database port is unset.")
function sqlLogger(query: string) {
console.log(compactSql(query))
}
// See https://sequelize.org/docs/v7/databases/postgres/
export const SEQUELIZE_CONFIG: Options<PostgresDialect> = {
dialect: PostgresDialect,
database: DB_DATABASE,
user: DB_USERNAME,
password: DB_PASSWORD,
host: DB_HOST,
port: DB_PORT,
ssl: NODE_ENV !== "production" ? false : { rejectUnauthorized: false },
schema: "public", // default - explicit for clarity
logging: SEQUELIZE_LOGGING ? sqlLogger : false,
pool: {
max: 20,
min: 2,
acquire: 60_000,
idle: 10_000,
evict: 10_000,
},
define: {
underscored: true,
timestamps: true, // default - explicit for clarity.
paranoid: true, // adds deleted_at column
},
}
const db = new Sequelize(SEQUELIZE_CONFIG)
export default db
+54
View File
@@ -0,0 +1,54 @@
import path from "path"
import knex, { Knex } from "knex"
import { isEmpty, isNil, merge } from "lodash"
import { DB_DATABASE, DB_HOST, DB_PASSWORD, DB_PORT, DB_USERNAME, NODE_ENV } from "@/config"
if (isEmpty(DB_DATABASE)) throw new Error("database name is unset.")
if (isEmpty(DB_USERNAME)) throw new Error("database username is unset.")
if (isEmpty(DB_PASSWORD)) throw new Error("database password is unset.")
if (isEmpty(DB_HOST)) throw new Error("database host is unset.")
if (isNil(DB_PORT) || isNaN(DB_PORT)) throw new Error("database port is unset.")
export function buildKnexConfig(options?: Knex.Config): Knex.Config {
return merge(
{
client: "pg",
connection: {
host: DB_HOST,
user: DB_USERNAME,
password: DB_PASSWORD,
database: DB_DATABASE,
port: DB_PORT,
ssl:
NODE_ENV !== "production"
? false
: {
require: true, // Enforce SSL
rejectUnauthorized: false, // Disable certificate verification (common for Azure)
},
/* options: {
encrypt: true,
trustServerCertificate: DB_TRUST_SERVER_CERTIFICATE,
}, */
},
migrations: {
directory: path.resolve(__dirname, "./migrations"),
extension: "ts",
stub: path.resolve(__dirname, "./templates/sample-migration.ts"),
},
seeds: {
directory: path.resolve(__dirname, `./seeds/${NODE_ENV}`),
extension: "ts",
stub: path.resolve(__dirname, "./templates/sample-seed.ts"),
},
},
options
)
}
const config = buildKnexConfig()
const dbMigrationClient = knex(config)
export default dbMigrationClient
@@ -0,0 +1,36 @@
import type { Knex } from "knex"
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("users", function (table) {
table.increments("id").notNullable().primary()
table.string("email", 100).notNullable()
table.string("auth0_subject", 100).notNullable()
table.string("first_name", 100).notNullable()
table.string("last_name", 100).notNullable()
table.string("display_name", 200).notNullable()
table.string("roles", 255).notNullable()
table
.specificType("created_at", "TIMESTAMP WITH TIME ZONE")
.notNullable()
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
table
.specificType("updated_at", "TIMESTAMP WITH TIME ZONE")
.notNullable()
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
table.specificType("deleted_at", "TIMESTAMP WITH TIME ZONE")
table.unique(["email"], {
indexName: "users_email_unique",
predicate: knex.whereNull("deleted_at"),
})
table.unique(["auth0_subject"], {
indexName: "users_auth0_subject_unique",
predicate: knex.whereNull("deleted_at"),
})
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable("users")
}
+74
View File
@@ -0,0 +1,74 @@
import express, { Request, Response } from "express"
import { join } from "path"
import { NODE_ENV } from "@/config"
import dbMigrationClient from "@/db/db-migration-client"
import { logger } from "@/utils/logger"
export class Migrator {
readonly migrationRouter
constructor() {
this.migrationRouter = express.Router()
this.migrationRouter.get("/", async (_req: Request, res: Response) => {
return res.json({ data: await this.listMigrations() })
})
this.migrationRouter.get("/up", async (_req: Request, res: Response) => {
try {
await this.migrateUp()
} catch (err) {
logger.error(err)
}
return res.json({ data: await migrator.listMigrations() })
})
this.migrationRouter.get("/down", async (_req: Request, res: Response) => {
try {
await this.migrateDown()
} catch (err) {
logger.error(err)
}
return res.json({ data: await this.listMigrations() })
})
this.migrationRouter.get("/seed/:environment", async (req: Request, res: Response) => {
try {
await this.seedUp(req.params.environment)
} catch (err) {
logger.error(err)
}
return res.json({ data: "Seeding" })
})
}
listMigrations() {
return dbMigrationClient.migrate.list({ directory: join(__dirname, "migrations") })
}
async migrateUp() {
logger.warn("-------- MIGRATE UP ---------")
return dbMigrationClient.migrate.up({ directory: join(__dirname, "migrations") })
}
async migrateDown() {
logger.warn("-------- MIGRATE DOWN ---------")
return dbMigrationClient.migrate.down({ directory: join(__dirname, "migrations") })
}
async migrateLatest() {
logger.warn("-------- MIGRATE LATEST ---------")
return dbMigrationClient.migrate.latest({ directory: join(__dirname, "migrations") })
}
async seedUp(environment?: string) {
logger.warn("-------- SEED UP ---------")
return dbMigrationClient.seed.run({
directory: join(__dirname, "seeds", environment || NODE_ENV),
})
}
}
const migrator = new Migrator()
export default migrator
@@ -0,0 +1,31 @@
import { CreationAttributes } from "@sequelize/core"
import { isNil } from "lodash"
import logger from "@/utils/logger"
import { CreateService } from "@/services/users"
import { User } from "@/models"
export async function seed(): Promise<void> {
const systemUserAttributes: CreationAttributes<User> = {
email: "system.user@alphane.com",
auth0Subject: "NO_LOGIN_system.user@alphane.com",
firstName: "System",
lastName: "User",
displayName: "System User",
roles: [User.Roles.SYSTEM_ADMIN],
}
const user = await User.findOne({
where: {
email: systemUserAttributes.email,
},
})
if (isNil(user)) {
const createdUser = await CreateService.perform(systemUserAttributes)
logger.debug("System User created:", createdUser.dataValues)
} else {
await user.update(systemUserAttributes)
logger.debug("System User updated:", user.dataValues)
}
}
@@ -0,0 +1,31 @@
import { CreationAttributes } from "@sequelize/core"
import { isNil } from "lodash"
import logger from "@/utils/logger"
import { CreateService } from "@/services/users"
import { User } from "@/models"
export async function seed(): Promise<void> {
const systemUserAttributes: CreationAttributes<User> = {
email: "system.user@alphane.com",
auth0Subject: "NO_LOGIN_system.user@alphane.com",
firstName: "System",
lastName: "User",
displayName: "System User",
roles: [User.Roles.SYSTEM_ADMIN],
}
const user = await User.findOne({
where: {
email: systemUserAttributes.email,
},
})
if (isNil(user)) {
const createdUser = await CreateService.perform(systemUserAttributes)
logger.debug("System User created:", createdUser.dataValues)
} else {
await user.update(systemUserAttributes)
logger.debug("System User updated:", user.dataValues)
}
}
View File
@@ -0,0 +1,31 @@
import { CreationAttributes } from "@sequelize/core"
import { isNil } from "lodash"
import logger from "@/utils/logger"
import { CreateService } from "@/services/users"
import { User } from "@/models"
export async function seed(): Promise<void> {
const systemUserAttributes: CreationAttributes<User> = {
email: "system.user@alphane.com",
auth0Subject: "NO_LOGIN_system.user@alphane.com",
firstName: "System",
lastName: "User",
displayName: "System User",
roles: [User.Roles.SYSTEM_ADMIN],
}
const user = await User.findOne({
where: {
email: systemUserAttributes.email,
},
})
if (isNil(user)) {
const createdUser = await CreateService.perform(systemUserAttributes)
logger.debug("System User created:", createdUser.dataValues)
} else {
await user.update(systemUserAttributes)
logger.debug("System User updated:", user.dataValues)
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { Knex } from "knex"
export async function up(knex: Knex): Promise<void> {
throw new Error("Not implemented")
}
export async function down(knex: Knex): Promise<void> {
throw new Error("Not implemented")
}
+33
View File
@@ -0,0 +1,33 @@
import { Knex } from "knex"
import { isNil } from "lodash"
import logger from "@/utils/logger"
import { User } from "@/models"
export async function seed(_knex: Knex): Promise<void> {
const usersAttributes = [
{
email: "system.user@richter-guardian.com",
auth0Subject: "system.user@richter-guardian.com",
firstName: "System",
lastName: "User",
displayName: "System User",
roles: [User.Roles.SYSTEM_ADMIN],
title: "System User",
},
]
for (const attributes of usersAttributes) {
let user = await User.findOne({
where: {
email: attributes.email,
},
})
if (isNil(user)) {
user = await User.create(attributes)
logger.debug("User created:", user.dataValues)
} else {
await user.update(attributes)
logger.debug("User updated:", user.dataValues)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import dbMigrationClient from "@/db/db-migration-client"
import { logger } from "@/utils/logger"
export async function isValidConnection() {
try {
await dbMigrationClient.raw("SELECT GETDATE()")
logger.info("Connection has been established successfully.")
return true
} catch (error) {
logger.error("Unable to connect to the database: " + error)
return false
}
}
if (require.main === module) {
isValidConnection()
}
@@ -0,0 +1,54 @@
import { Knex } from "knex"
import { QueryTypes, QueryOptionsWithType } from "@sequelize/core"
import db from "@/db/db-client"
type QueryOptions = Omit<QueryOptionsWithType<QueryTypes.SELECT>, "bind" | "type">
// TODO: fix types to show that it might return null
export async function knexQueryToSequelizeSelect<T extends object>(
knexQuery: Knex.QueryBuilder,
options: QueryOptions = {}
) {
const { sql: knexSql, bindings } = knexQuery.toSQL().toNative()
const { sql: sequelizeSql, bind } = knexSqlNativeToSequelizeQueryWithBind({
sql: knexSql,
bindings,
})
return db.query<T>(sequelizeSql, {
...options,
bind,
type: QueryTypes.SELECT,
})
}
/**
* Note siganture is chosen so you can pass knexQuery.toSQL().toNative() directly
*
* Currently only tested with MSSQL dialect
*
* @param sqlWithKnexBindings knexQuery.toSQL().toNative().sql
* @param bindings knexQuery.toSQL().toNative().bindings
* @returns { sql: string, bind: unknown[] } in Sequelize format
*/
export function knexSqlNativeToSequelizeQueryWithBind({
sql: sqlWithKnexBindings,
bindings,
}: {
sql: string
bindings: readonly unknown[]
}): { sql: string; bind: unknown[] } {
let sqlWithSequelizeBindings = sqlWithKnexBindings
// converts "@p0" to "$1", "@p1" to "$2", etc.
bindings.forEach((_, i) => {
const pattern = new RegExp(`@p${i}\\b`, "g")
sqlWithSequelizeBindings = sqlWithSequelizeBindings.replace(pattern, `$${i + 1}`)
})
const mutableBindings = [...bindings]
return {
sql: sqlWithSequelizeBindings,
bind: mutableBindings,
}
}
+18
View File
@@ -0,0 +1,18 @@
import { logger } from "@/utils/logger"
export function safeJsonParse(values: string): any[] {
try {
const lines = JSON.parse(values)
if (Array.isArray(lines)) {
return lines
} else {
logger.error("Parsed value is not an array.")
return []
}
} catch (error) {
logger.error(`Error parsing JSON: ${error}`, { error })
return []
}
}
export default safeJsonParse
@@ -0,0 +1,103 @@
import knex, { type Knex } from "knex"
import {
DB_HEALTH_CHECK_INTERVAL_SECONDS,
DB_HEALTH_CHECK_RETRIES,
DB_HEALTH_CHECK_START_PERIOD_SECONDS,
DB_HEALTH_CHECK_TIMEOUT_SECONDS,
} from "@/config"
import logger from "@/utils/logger"
import sleep from "@/utils/sleep"
import {
isCredentialFailure,
isNetworkFailure,
isSocketFailure,
isMissingDatabaseFailure,
} from "@/utils/db-error-helpers"
import { buildKnexConfig } from "@/db/db-migration-client"
function checkHealth(dbMigrationClient: Knex, timeoutSeconds: number) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Connection timeout")), timeoutSeconds * 1000)
dbMigrationClient
.raw("SELECT 1")
.then(() => {
clearTimeout(timer)
resolve(null)
})
.catch(reject)
})
}
export async function waitForDatabase({
intervalSeconds = DB_HEALTH_CHECK_INTERVAL_SECONDS,
timeoutSeconds = DB_HEALTH_CHECK_TIMEOUT_SECONDS,
retries = DB_HEALTH_CHECK_RETRIES,
startPeriodSeconds = DB_HEALTH_CHECK_START_PERIOD_SECONDS,
}: {
intervalSeconds?: number
timeoutSeconds?: number
retries?: number
startPeriodSeconds?: number
} = {}): Promise<void> {
await sleep(startPeriodSeconds)
logger.info("Attempting direct to database connection...")
const databaseConfig = buildKnexConfig()
let dbMigrationClient = knex(databaseConfig)
let isDatabaseSocketReady = false
for (let i = 0; i < retries; i++) {
try {
await checkHealth(dbMigrationClient, timeoutSeconds)
logger.info("Database connection successful.")
return
} catch (error) {
if (isSocketFailure(error)) {
logger.info(`Database socket is not ready, retrying... ${error}`, { error })
await sleep(intervalSeconds)
} else if (isNetworkFailure(error)) {
logger.info(`Network error, retrying... ${error}`, { error })
await sleep(intervalSeconds)
} else if (isCredentialFailure(error)) {
if (isDatabaseSocketReady) {
logger.error(`Database connection failed due to invalid credentials: ${error}`, { error })
throw error
} else {
logger.info(
"Falling back to database server-level connection (database might not exist)..."
)
const serverLevelConfig = buildKnexConfig({ connection: { database: "" } })
dbMigrationClient = knex(serverLevelConfig)
i -= 1
isDatabaseSocketReady = true
continue
}
} else if (isMissingDatabaseFailure(error)) {
if (isDatabaseSocketReady) {
logger.error(`Database connection failed because database does not exist): ${error}`, {
error,
})
throw error
} else {
logger.info(
"Falling back to default postgres connection (after database does not exist failure)..."
)
const serverLevelConfig = buildKnexConfig({ connection: { database: "postgres" } })
dbMigrationClient = knex(serverLevelConfig)
i -= 1
isDatabaseSocketReady = true
continue
}
} else {
logger.error(`Unknown database connection error: ${error}`, { error })
//throw error
}
}
}
throw new Error(`Failed to connect to the database due to timeout.`)
}
export default waitForDatabase
@@ -0,0 +1,71 @@
import knex, { type Knex } from "knex"
import { logger } from "@/utils/logger"
import { isCredentialFailure, isMissingDatabaseFailure } from "@/utils/db-error-helpers"
import { buildKnexConfig } from "@/db/db-migration-client"
import { DB_DATABASE } from "@/config"
async function databaseExists(dbMigrationClient: Knex, databaseName: string): Promise<boolean> {
const result = await dbMigrationClient.raw("SELECT 1 FROM pg_database WHERE datname = ?", [
databaseName,
])
return result.rows.length > 0
}
async function createDatabase(): Promise<true> {
logger.info("Attempting direct to database connection to determine if database exists...")
const databaseConfig = buildKnexConfig()
let dbMigrationClient = knex(databaseConfig)
let isCredentialFailureError = false
let isMissingDatabaseError = false
try {
if (await databaseExists(dbMigrationClient, DB_DATABASE)) {
return true
}
} catch (error) {
if (isCredentialFailure(error)) {
isCredentialFailureError = true
logger.info("Database connection failed due to invalid credential, retrying...")
}
if (isMissingDatabaseFailure(error)) {
isMissingDatabaseError = true
logger.info("Database connection failed due missing default database, retrying...")
} else {
logger.error(`Unknown connection failure, could not determine if database exists: ${error}`, {
error,
})
throw error
}
}
if (isCredentialFailureError || isMissingDatabaseError) {
logger.info("Attempting server-level connection to determine if database exists...")
const serverLevelConfig = buildKnexConfig({ connection: { database: "" } })
dbMigrationClient = knex(serverLevelConfig)
try {
if (await databaseExists(dbMigrationClient, DB_DATABASE)) {
return true
}
} catch (error) {
logger.error(
`Could not determine if database exists database with server-level connection: ${error}`,
{ error }
)
throw error
}
}
logger.info(`Database ${DB_DATABASE} does not exist: creating...`)
try {
await dbMigrationClient.raw(`CREATE DATABASE ${DB_DATABASE}`)
} catch (error) {
logger.error(`Failed to create database: ${error}`, { error })
throw error
}
return true
}
export default createDatabase
+31
View File
@@ -0,0 +1,31 @@
import dbMigrationClient from "@/db/db-migration-client"
import { logger } from "@/utils/logger"
type MigrationInfo = {
file: string
directory: string
}
async function runMigrations(): Promise<void> {
const [_completedMigrations, pendingMigrations]: [MigrationInfo[], MigrationInfo[]] =
await dbMigrationClient.migrate.list()
if (pendingMigrations.length === 0) {
logger.info("No pending migrations.")
return
}
for (const { file, directory } of pendingMigrations) {
logger.info(`Running migration: ${directory}/${file}`)
try {
await dbMigrationClient.migrate.up()
} catch (error) {
logger.error(`Error running migration: ${error}`, { error })
throw error
}
}
logger.info("All migrations completed successfully.")
}
export default runMigrations
+25
View File
@@ -0,0 +1,25 @@
import { logger } from "@/utils/logger"
import dbMigrationClient from "@/db/db-migration-client"
import { User } from "@/models"
export async function runSeeds(): Promise<void> {
if (process.env.SKIP_SEEDING_UNLESS_EMPTY === "true") {
const count = await User.count({ logging: false })
if (count > 0) {
logger.warn("Skipping seeding as SKIP_SEEDING_UNLESS_EMPTY set, and data already seeded.")
return
}
}
try {
await dbMigrationClient.seed.run()
} catch (error) {
logger.error(`Error running seeds: ${error}`, { error })
throw error
}
return
}
export default runSeeds
+39
View File
@@ -0,0 +1,39 @@
import { logger } from "@/utils/logger"
import * as fs from "fs/promises"
import * as path from "path"
const NON_INITIALIZER_REGEX = /^index\.(ts|js)$/
export async function importAndExecuteInitializers() {
const files = await fs.readdir(__dirname)
for (const file of files) {
if (NON_INITIALIZER_REGEX.test(file)) continue
const modulePath = path.join(__dirname, file)
logger.info(`Running initializer: ${modulePath}`)
try {
const { default: initializerAction } = await require(modulePath)
await initializerAction()
} catch (error) {
logger.error(`Failed to run initializer: ${modulePath}`, { error })
throw error
}
}
return true
}
if (require.main === module) {
// TODO: add some kind of middleware that 503s? if initialization failed?
;(async () => {
try {
await importAndExecuteInitializers()
} catch {
logger.error("Failed to complete initialization!")
}
process.exit(0)
})()
}
+4
View File
@@ -0,0 +1,4 @@
# api/src/integrations/README.md
Integrations are api integrations with external services.
They might package a bunch of api calls, or just one.
+56
View File
@@ -0,0 +1,56 @@
import axios from "axios"
import { AUTH0_DOMAIN } from "@/config"
const auth0Api = axios.create({
baseURL: AUTH0_DOMAIN,
})
export interface Auth0UserInfo {
email: string
firstName: string
lastName: string
auth0Subject: string
}
export interface Auth0Response {
sub: string // "auth0|6241ec44e5b4a700693df293"
given_name: string // "Jane"
family_name: string // "Doe"
nickname: string // "Jane"
name: string // "Jane Doe"
picture: string // https://s.gravatar.com/avatar/1234567890abcdef1234567890abcdef?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fmb.png
updated_at: string // "2023-10-30T17:25:52.975Z"
email: string // "janedoe@gmail.com"
email_verified: boolean // true
oid?: string // 11111111-2222-3333-4444-555555555555"
}
export class Auth0PayloadError extends Error {
constructor(data: unknown) {
super(`Payload from Auth0 is strange or failed for: ${JSON.stringify(data)}`)
this.name = "Auth0PayloadError"
}
}
export const auth0Integration = {
async getUserInfo(token: string): Promise<Auth0UserInfo> {
const { data }: { data: Auth0Response } = await auth0Api.get("/userinfo", {
headers: { authorization: token },
})
const firstName = data.given_name || "UNKNOWN"
const lastName = data.family_name || "UNKNOWN"
const fallbackEmail = `${firstName}.${lastName}@yukon-no-email.ca`
const email = data.email || fallbackEmail
return {
auth0Subject: data.sub,
email,
firstName,
lastName,
}
},
}
export default auth0Integration
+5
View File
@@ -0,0 +1,5 @@
export {
auth0Integration,
Auth0PayloadError,
type Auth0UserInfo,
} from "./auth0-integration"
+38
View File
@@ -0,0 +1,38 @@
import logger from "@/utils/logger"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HasNoArgsConstructor<T> = T extends { new (): any } ? true : false
type CleanConstructorParameters<T extends typeof BaseJob> =
HasNoArgsConstructor<T> extends true ? [] : ConstructorParameters<T>
export class BaseJob {
protected filename: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
this.filename = args[0] || __filename
}
static perform<T extends typeof BaseJob>(
this: T,
...args: CleanConstructorParameters<T>
): ReturnType<InstanceType<T>["perform"]> {
try {
const instance = new this(...args)
const { filename } = instance
logger.debug(`## Performing Job: ${filename}`)
return instance.perform()
} catch (error) {
logger.error(`Failed to perform job: ${error}`, { error })
throw new Error(`Failed to perform job: ${error}`)
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
perform(): any {
throw new Error("Not Implemented")
}
}
export default BaseJob
+1
View File
@@ -0,0 +1 @@
+3
View File
@@ -0,0 +1,3 @@
# api/src/middlewares/README.md
Middleware are actions that run before or after every request.
@@ -0,0 +1,53 @@
import { type NextFunction, type Response } from "express"
import { type Request as JwtRequest } from "express-jwt"
import { isNil } from "lodash"
import { logger } from "@/utils/logger"
import { Auth0PayloadError } from "@/integrations/auth0-integration"
import { User } from "@/models"
import { Users } from "@/services"
export type AuthorizationRequest = JwtRequest & {
currentUser?: User | null
}
/**
* Requires api/src/middlewares/jwt-middleware.ts to be run first
*
* I'd love to merge that code in here at some point, or make all this code a controller "before action"
* I'm uncomfortable with creating users automatically here, I'd rather the front-end requested
* user creation directly, and might switch to that in the future.
*
* NOTE: must be kept in sync with api/tests/support/mock-current-user.ts
*/
export async function authorizationMiddleware(
req: AuthorizationRequest,
res: Response,
next: NextFunction
) {
const user = await User.withScope(["asCurrentUser"]).findOne({
where: {
auth0Subject: req.auth?.sub || "",
},
})
if (!isNil(user)) {
req.currentUser = user
return next()
}
try {
const token = req.headers.authorization || ""
const user = await Users.EnsureFromAuth0TokenService.perform(token)
req.currentUser = user
return next()
} catch (error) {
logger.error(`Error ensuring user from Auth0 token ${error}`, { error })
if (error instanceof Auth0PayloadError) {
return res.status(502).json({ message: "External authorization api failed." })
} else {
return res.status(401).json({ message: "User authentication failed." })
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export { authorizationMiddleware } from "./authorization-middleware"
export { jwtMiddleware } from "./jwt-middleware"
export { requestLoggerMiddleware } from "./request-logger-middleware"
+26
View File
@@ -0,0 +1,26 @@
import { expressjwt as jwt } from "express-jwt"
import jwksRsa, { type GetVerificationKey } from "jwks-rsa"
import { AUTH0_DOMAIN, AUTH0_AUDIENCE, NODE_ENV } from "@/config"
import { logger } from "@/utils/logger"
if (NODE_ENV !== "test") {
logger.info(`AUTH0_DOMAIN - ${AUTH0_DOMAIN}/.well-known/jwks.json`)
}
// TODO: investigate converting this to an integration or utility of the authorization middleware
export const jwtMiddleware = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${AUTH0_DOMAIN}/.well-known/jwks.json`,
}) as GetVerificationKey,
// Validate the audience and the issuer.
audience: AUTH0_AUDIENCE,
issuer: [`${AUTH0_DOMAIN}/`],
algorithms: ["RS256"],
})
export default jwtMiddleware
@@ -0,0 +1,14 @@
import morgan from "morgan"
import logger from "@/utils/logger"
export const requestLoggerMiddleware = morgan(
":method :url :status :res[content-length] - :response-time ms",
{
stream: {
write: (message) => logger.http(message.trim()),
},
}
)
export default requestLoggerMiddleware
+147
View File
@@ -0,0 +1,147 @@
import {
AttributeNames,
Attributes,
CreationOptional,
FindOptions,
Model,
ModelStatic,
Op,
WhereOptions,
} from "@sequelize/core"
import { searchFieldsByTermsFactory } from "@/utils/search-fields-by-terms-factory"
// See api/node_modules/@sequelize/core/lib/model.d.ts -> Model
export abstract class BaseModel<
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
TModelAttributes extends {} = any,
// eslint-disable-next-line @typescript-eslint/ban-types
TCreationAttributes extends {} = TModelAttributes,
> extends Model<TModelAttributes, TCreationAttributes> {
declare id: CreationOptional<number>
static addSearchScope<M extends BaseModel>(this: ModelStatic<M>, fields: AttributeNames<M>[]) {
const searchScopeFunction = searchFieldsByTermsFactory<M>(fields)
this.addScope("search", searchScopeFunction)
}
// static findByPk<M extends Model, R = Attributes<M>>(
// this: ModelStatic<M>,
// identifier: unknown,
// options: FindByPkOptions<M> & { raw: true; rejectOnEmpty?: false },
// ): Promise<R | null>;
// static findByPk<M extends Model, R = Attributes<M>>(
// this: ModelStatic<M>,
// identifier: unknown,
// options: NonNullFindByPkOptions<M> & { raw: true },
// ): Promise<R>;
// static findByPk<M extends Model>(
// this: ModelStatic<M>,
// identifier: unknown,
// options: NonNullFindByPkOptions<M>,
// ): Promise<M>;
// static findByPk<M extends Model>(
// this: ModelStatic<M>,
// identifier: unknown,
// options?: FindByPkOptions<M>,
// ): Promise<M | null>;
public static async findByIdentifierOrPk<M extends BaseModel>(
this: ModelStatic<M>,
identifierOrPk: string | number,
options?: Omit<FindOptions<Attributes<M>>, "where">
): Promise<M | null> {
if (typeof identifierOrPk === "number" || !isNaN(Number(identifierOrPk))) {
const primaryKey = identifierOrPk
return this.findByPk(primaryKey, options)
}
const identifier = identifierOrPk
if (!("identifier" in this.getAttributes())) {
throw new Error(`${this.name} does not have a 'identifier' attribute.`)
}
return this.findOne({
...options,
// @ts-expect-error - We know that the model has a slug attribute, and are ignoring the TS error
where: { identifier },
})
}
// See api/node_modules/@sequelize/core/lib/model.d.ts -> findAll
// Taken from https://api.rubyonrails.org/v7.1.0/classes/ActiveRecord/Batches.html#method-i-find_each
// Enforces sort by id, overwriting any supplied order
public static async findEach<M extends BaseModel>(
this: ModelStatic<M>,
processFunction: (record: M) => Promise<void>
): Promise<void>
public static async findEach<M extends BaseModel, R = Attributes<M>>(
this: ModelStatic<M>,
options: Omit<FindOptions<Attributes<M>>, "raw"> & {
raw: true
batchSize?: number
},
processFunction: (record: R) => Promise<void>
): Promise<void>
public static async findEach<M extends BaseModel>(
this: ModelStatic<M>,
options: FindOptions<Attributes<M>> & {
batchSize?: number
},
processFunction: (record: M) => Promise<void>
): Promise<void>
public static async findEach<M extends BaseModel, R = Attributes<M>>(
this: ModelStatic<M>,
optionsOrFunction:
| ((record: M) => Promise<void>)
| (Omit<FindOptions<Attributes<M>>, "raw"> & { raw: true; batchSize?: number })
| (FindOptions<Attributes<M>> & { batchSize?: number }),
maybeFunction?: (record: R | M) => Promise<void>
): Promise<void> {
let options:
| (FindOptions<Attributes<M>> & { batchSize?: number })
| (Omit<FindOptions<Attributes<M>>, "raw"> & { raw: true; batchSize?: number })
// TODO: fix types so that process function is M when not raw
// and R when raw. Raw is usable, just incorrectly typed.
let processFunction: (record: M) => Promise<void>
if (typeof optionsOrFunction === "function") {
options = {}
processFunction = optionsOrFunction
} else if (maybeFunction === undefined) {
throw new Error("findEach requires a processFunction")
} else {
options = optionsOrFunction
processFunction = maybeFunction
}
const batchSize = options.batchSize ?? 1000
let lastId = 0
let continueProcessing = true
while (continueProcessing) {
// TODO: fix where option types so cast is not needed
const whereClause = {
...options.where,
id: { [Op.gt]: lastId },
} as WhereOptions<Attributes<M>>
const records = await this.findAll({
...options,
where: whereClause,
limit: batchSize,
order: [["id", "ASC"]],
})
for (const record of records) {
await processFunction(record)
lastId = record.id
}
if (records.length < batchSize) {
continueProcessing = false
}
}
}
}
export default BaseModel
+19
View File
@@ -0,0 +1,19 @@
import db from "@/db/db-client"
// Models
import User, { UserRoles } from "@/models/user"
db.addModels([
User,
])
// Lazy load scopes
User.establishScopes()
export {
User,
UserRoles,
}
// Special db instance will all models loaded
export default db
+110
View File
@@ -0,0 +1,110 @@
import {
type CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
type NonAttribute,
sql,
} from "@sequelize/core"
import {
Attribute,
AutoIncrement,
Default,
Index,
NotNull,
PrimaryKey,
ValidateAttribute,
} from "@sequelize/core/decorators-legacy"
import { isArray, isNil } from "lodash"
import BaseModel from "@/models/base-model"
/** Keep in sync with web/src/api/users-api.ts */
export enum UserRoles {
SYSTEM_ADMIN = "system_admin",
USER = "user",
}
export class User extends BaseModel<InferAttributes<User>, InferCreationAttributes<User>> {
static readonly Roles = UserRoles
@Attribute(DataTypes.INTEGER)
@PrimaryKey
@AutoIncrement
declare id: CreationOptional<number>
@Attribute(DataTypes.STRING(100))
@NotNull
@Index({ unique: true })
declare email: string
@Attribute(DataTypes.STRING(100))
@NotNull
@Index({ unique: true })
declare auth0Subject: string
@Attribute(DataTypes.STRING(100))
@NotNull
declare firstName: string
@Attribute(DataTypes.STRING(100))
@NotNull
declare lastName: string
@Attribute(DataTypes.STRING(200))
@NotNull
declare displayName: string
@Attribute({
type: DataTypes.STRING(255),
get() {
const roles = this.getDataValue("roles")
if (isNil(roles)) {
return []
}
return roles.split(",")
},
set(value: string[]) {
this.setDataValue("roles", value.join(","))
},
})
@NotNull
@ValidateAttribute({
validator: (valueString: string | string[]) => {
const value = isArray(valueString) ? valueString : valueString.split(",")
const validRoles = Object.values(UserRoles) as string[]
const invalidRoles = value.filter((role) => !validRoles.includes(role))
if (invalidRoles.length > 0) throw new Error(`Invalid role: ${invalidRoles.join(", ")}`)
},
})
declare roles: UserRoles[]
@Attribute(DataTypes.DATE(0))
@NotNull
@Default(sql.literal("CURRENT_TIMESTAMP"))
declare createdAt: CreationOptional<Date>
@Attribute(DataTypes.DATE(0))
@NotNull
@Default(sql.literal("CURRENT_TIMESTAMP"))
declare updatedAt: CreationOptional<Date>
@Attribute(DataTypes.DATE(0))
declare deletedAt: Date | null
// Magic Attributes
get isSystemAdmin(): NonAttribute<boolean> {
return this.roles.some((role) => role === UserRoles.SYSTEM_ADMIN)
}
// Associations
// Scopes
static establishScopes(): void {
this.addSearchScope(["firstName", "lastName", "displayName", "email"])
this.addScope("asCurrentUser", {})
}
}
export default User
+254
View File
@@ -0,0 +1,254 @@
# Policies
Policies are used to control access to data in a controller, before it is returned to the client.
Polices can be used in the following ways:
1. Build a policy instance and check the controller action matching boolean function.
Controller#update -> Policy#update
```ts
export class AccessGrantsController extends BaseController {
async update() {
const accessGrant = await this.loadAccessGrant()
if (isNil(accessGrant)) {
return this.response.status(404).json({ message: "Access grant not found." })
}
const policy = this.buildPolicy(accessGrant)
if (!policy.update()) {
return this.response
.status(403)
.json({ message: "You are not authorized to update access grants on this dataset." })
}
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
try {
const updatedAccessGrant = await UpdateService.perform(
accessGrant,
permittedAttributes,
this.currentUser
)
return this.response.status(200).json({ accessGrant: updatedAccessGrant })
} catch (error) {
return this.response.status(422).json({ message: `Access grant update failed: ${error}` })
}
}
private async loadAccessGrant(): Promise<AccessGrant | null> {
return AccessGrant.findByPk(this.params.accessGrantId)
}
private buildPolicy(accessGrant: AccessGrant) {
return new AccessGrantsPolicy(this.currentUser, accessGrant)
}
}
```
2. The previous example also demostrates a second way of using policies. The "permitted attributes" pattern. A policy can also be used to provide an "allow list" of attributes that a user is allowed to submit for a given controller action.
```ts
export class AccessGrantsPolicy extends BasePolicy<AccessGrant> {
permittedAttributes(): Path[] {
return ["supportId", "grantLevel", "accessType", "isProjectDescriptionRequired"]
}
}
```
3. Policies can also be used to restrict the results of an "index" or list action in a controller.
In this case a bunch of scoping conditions are built up, and then passed to the "apply scope" function. This produces a query that, when executed, will only return the records that the current user is allowed to see.
```ts
export class AccessGrantsController extends BaseController<AccessGrant> {
async index() {
const where = this.buildWhere()
const scopes = this.buildFilterScopes()
const scopedAccessGrants = AccessGrantsPolicy.applyScope(scopes, this.currentUser)
const totalCount = await scopedAccessGrants.count({ where })
const accessGrants = await scopedAccessGrants.findAll({
where,
limit: this.pagination.limit,
offset: this.pagination.offset,
})
return this.response.json({ accessGrants, totalCount })
}
}
```
## Policy#policyScope
The `policyScope` method is used to add a scope to the given model. This scope is permanently added to the model, though it likely shouldn't be used outside of the policy.
i.e.
```ts
export class AccessRequestsPolicy extends PolicyFactory(AccessRequest) {
static policyScope(user: User): FindOptions<Attributes<AccessRequest>> {
if (user.isSystemAdmin || user.isBusinessAnalyst) {
return {}
}
if (user.isDataOwner) {
return {
include: [
{
association: "dataset",
where: {
ownerId: user.id,
},
},
],
}
}
return {
where: {
requestorId: user.id,
},
}
}
}
```
can be considered equivalent to
```ts
AccessReqeuest.addScope("policyScope", (user: User) => {
if (user.isSystemAdmin || user.isBusinessAnalyst) {
return {}
}
if (user.isDataOwner) {
return {
include: [
{
association: "dataset",
where: {
ownerId: user.id,
},
},
],
}
}
return {
where: {
requestorId: user.id,
},
}
})
```
# Full Example
Here is a simple example of a controller using a policy to control access to a resource.
The full cases might be more complex, but the "policy" pattern leaves space for that complexity to exist without cluttering the controller.
```ts
export class AccessGrantsController extends BaseController<AccessGrant> {
async index() {
const where = this.buildWhere()
const scopes = this.buildFilterScopes()
const scopedAccessGrants = AccessGrantsPolicy.applyScope(scopes, this.currentUser)
const totalCount = await scopedAccessGrants.count({ where })
const accessGrants = await scopedAccessGrants.findAll({
where,
limit: this.pagination.limit,
offset: this.pagination.offset,
})
return this.response.json({ accessGrants, totalCount })
}
async create() {
const accessGrant = await this.buildAccessGrant()
if (isNil(accessGrant)) {
return this.response.status(404).json({ message: "Dataset not found." })
}
const policy = this.buildPolicy(accessGrant)
if (!policy.create()) {
return this.response
.status(403)
.json({ message: "You are not authorized to add access grants for this dataset." })
}
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
try {
const accessGrant = await CreateService.perform(permittedAttributes, this.currentUser)
return this.response.status(201).json({ accessGrant })
} catch (error) {
return this.response.status(422).json({ message: `Access grant creation failed: ${error}` })
}
}
async update() {
const accessGrant = await this.loadAccessGrant()
if (isNil(accessGrant)) {
return this.response.status(404).json({ message: "Access grant not found." })
}
const policy = this.buildPolicy(accessGrant)
if (!policy.update()) {
return this.response
.status(403)
.json({ message: "You are not authorized to update access grants on this dataset." })
}
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
try {
const updatedAccessGrant = await UpdateService.perform(
accessGrant,
permittedAttributes,
this.currentUser
)
return this.response.status(200).json({ accessGrant: updatedAccessGrant })
} catch (error) {
return this.response.status(422).json({ message: `Access grant update failed: ${error}` })
}
}
private async buildAccessGrant(): Promise<AccessGrant> {
return AccessGrant.build(this.request.body)
}
private async loadAccessGrant(): Promise<AccessGrant | null> {
return AccessGrant.findByPk(this.params.accessGrantId)
}
private buildPolicy(accessGrant: AccessGrant) {
return new AccessGrantsPolicy(this.currentUser, accessGrant)
}
}
```
and the policy
```ts
export class AccessGrantsPolicy extends BasePolicy<AccessGrant> {
create(): boolean {
// some code that might returns true
return false
}
update(): boolean {
// some code that might returns true
return false
}
destroy(): boolean {
// some code that might returns true
return false
}
permittedAttributes(): Path[] {
return ["supportId", "grantLevel", "accessType", "isProjectDescriptionRequired"]
}
permittedAttributesForCreate(): Path[] {
return ["datasetId", ...this.permittedAttributes()]
}
}
```
+146
View File
@@ -0,0 +1,146 @@
import { ModelStatic, Model, Attributes, FindOptions, ScopeOptions, literal } from "@sequelize/core"
import { User } from "@/models"
import { Path, deepPick } from "@/utils/deep-pick"
import { isInteger, isNil } from "lodash"
export type Actions = "show" | "create" | "update" | "destroy"
export const NO_RECORDS_SCOPE = { where: literal("1 = 0") }
export const ALL_RECORDS_SCOPE = {}
/**
* See PolicyFactory below for policy with scope helpers
*/
export class BasePolicy<M extends Model> {
protected user: User
protected record: M
constructor(user: User, record: M) {
this.user = user
this.record = record
}
show(): boolean {
return false
}
create(): boolean {
return false
}
update(): boolean {
return false
}
destroy(): boolean {
return false
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static policyScope<M extends Model>(user: User, ...args: unknown[]): FindOptions<Attributes<M>> {
throw new Error("Derived classes must implement policyScope method")
}
permitAttributes(record: Partial<M>): Partial<M> {
return deepPick(record, this.permittedAttributes())
}
permitAttributesForCreate(record: Partial<M>): Partial<M> {
if (this.permittedAttributesForCreate !== BasePolicy.prototype.permittedAttributesForCreate) {
return deepPick(record, this.permittedAttributesForCreate())
} else {
return deepPick(record, this.permittedAttributes())
}
}
permitAttributesForUpdate(record: Partial<M>): Partial<M> {
if (this.permittedAttributesForUpdate !== BasePolicy.prototype.permittedAttributesForUpdate) {
return deepPick(record, this.permittedAttributesForUpdate())
} else {
return deepPick(record, this.permittedAttributes())
}
}
permittedAttributes(): Path[] {
throw new Error("Not Implemented")
}
permittedAttributesForCreate(): Path[] {
throw new Error("Not Implemented")
}
permittedAttributesForUpdate(): Path[] {
throw new Error("Not Implemented")
}
setNumberNullIfEmpty(input: number | null | undefined): number | null | undefined {
let output = input
if (!isNil(input) && !isInteger(input)) output = undefined
if (!isNil(input) && input == 0) output = undefined
return output
}
setNumberZeroIfEmpty(input: number | null | undefined): number {
let output = input
if (!isNil(input) && !isInteger(input)) output = 0
if (!isNil(input) && input == 0) output = 0
return output ?? 0
}
/**
* Add to support return policy information via this.reponse.json({ someObject, policy })
*
* If this method becomes complex, it should be broken out into a serializer.
*
* @returns a JSON representation of the policy
*/
toJSON(): Record<Actions, boolean> {
return {
show: this.show(),
create: this.create(),
update: this.update(),
destroy: this.destroy(),
}
}
}
// See api/node_modules/sequelize/types/model.d.ts -> Model -> scope
export type BaseScopeOptions = string | ScopeOptions
export const POLICY_SCOPE_NAME = "policyScope"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AllArgsButFirstOne<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never
export function PolicyFactory<M extends Model, T extends Model = M>(modelClass: ModelStatic<M>) {
const policyClass = class Policy extends BasePolicy<T> {
static applyScope<P extends typeof Policy>(
this: P,
scopes: BaseScopeOptions[],
user: User,
...extraPolicyScopeArgs: AllArgsButFirstOne<Parameters<P["policyScope"]>>
): ModelStatic<M> {
this.ensurePolicyScope()
return modelClass.withScope([
...scopes,
{ method: [POLICY_SCOPE_NAME, user, ...extraPolicyScopeArgs] },
])
}
/**
* Just in time scope creation for model class.
* TODO: to have scope creation occur at definition time, instead of execution time.
*/
static ensurePolicyScope() {
if (Object.prototype.hasOwnProperty.call(modelClass.options.scopes, POLICY_SCOPE_NAME)) {
return
}
modelClass.addScope(POLICY_SCOPE_NAME, this.policyScope.bind(modelClass))
}
}
return policyClass
}
export default PolicyFactory
+3
View File
@@ -0,0 +1,3 @@
// Policy Bundles
export { type BaseScopeOptions } from "./base-policy"
export { UsersPolicy } from "./users-policy"
+79
View File
@@ -0,0 +1,79 @@
import { Attributes, FindOptions } from "@sequelize/core"
import { Path } from "@/utils/deep-pick"
import { User } from "@/models"
import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy"
export class UsersPolicy extends PolicyFactory(User) {
show(): boolean {
if (this.user.isSystemAdmin) {
return true
}
if (this.user.id === this.record.id) {
return true
}
return false
}
create(): boolean {
if (this.user.isSystemAdmin) {
return true
}
return false
}
update(): boolean {
if (this.user.isSystemAdmin) {
return true
}
if (this.user.id === this.record.id) {
return true
}
return false
}
destroy(): boolean {
if (this.user.id === this.record.id) {
return false
}
if (this.user.isSystemAdmin) {
return true
}
return false
}
permittedAttributes(): Path[] {
const attributes: (keyof Attributes<User>)[] = [
"email",
"auth0Subject",
"firstName",
"lastName",
"displayName",
]
return attributes
}
permittedAttributesForCreate(): Path[] {
return [...this.permittedAttributes()]
}
permittedAttributesForUpdate(): Path[] {
return [...this.permittedAttributes()]
}
static policyScope(user: User): FindOptions<Attributes<User>> {
if (user.isSystemAdmin) return ALL_RECORDS_SCOPE
return { where: { id: user.id } }
}
}
export default UsersPolicy
+91
View File
@@ -0,0 +1,91 @@
import path from "path"
import fs from "fs"
import {
Router,
type Request,
type Response,
type ErrorRequestHandler,
type NextFunction,
} from "express"
import { UnauthorizedError } from "express-jwt"
import { template } from "lodash"
import { APPLICATION_NAME, GIT_COMMIT_HASH, NODE_ENV, RELEASE_TAG } from "@/config"
import { logger } from "@/utils/logger"
import { jwtMiddleware, authorizationMiddleware } from "@/middlewares"
import { CurrentUserController, UsersController } from "@/controllers"
export const router = Router()
// non-api (no authentication is required) routes
router.route("/_status").get((_req: Request, res: Response) => {
return res.json({
RELEASE_TAG,
GIT_COMMIT_HASH,
})
})
// external (public) routes - no authentication required
// api routes
router.use("/api", jwtMiddleware, authorizationMiddleware)
router.route("/api/current-user").get(CurrentUserController.show)
router.route("/api/users").get(UsersController.index).post(UsersController.create)
router
.route("/api/users/:userId")
.get(UsersController.show)
.patch(UsersController.update)
.delete(UsersController.destroy)
// if no other routes match, return a 404
router.use("/api", (req: Request, res: Response) => {
return res.status(404).json({ message: "Not Found", url: req.path })
})
// Special error handler for all api errors
// See https://expressjs.com/en/guide/error-handling.html#writing-error-handlers
router.use("/api", (err: ErrorRequestHandler, _req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
return next(err)
}
if (err instanceof UnauthorizedError) {
logger.error(err)
return res.status(err.status).json({ message: err.inner.message })
}
/* if (err instanceof DatabaseError) {
logger.error(err)
return res.status(422).json({ message: "Invalid query against database." })
}
*/
logger.error(err)
return res.status(500).json({ message: "Internal Server Error" })
})
// if no other non-api routes match, send the pretty 404 page
if (NODE_ENV == "development") {
router.use("/", (_req: Request, res: Response) => {
const templatePath = path.resolve(__dirname, "web/404.html")
try {
const templateString = fs.readFileSync(templatePath, "utf8")
const compiledTemplate = template(templateString)
const result = compiledTemplate({
applicationName: APPLICATION_NAME,
releaseTag: RELEASE_TAG,
gitCommitHash: GIT_COMMIT_HASH,
})
return res.status(404).send(result)
} catch (error) {
logger.error(error)
return res.status(500).send(`Error building 404 page: ${error}`)
}
})
}
export default router
+38
View File
@@ -0,0 +1,38 @@
import { CronJob } from "cron"
import { API_PORT, APPLICATION_NAME } from "@/config"
import logger from "@/utils/logger"
import withLoggingFactory from "@/utils/with-logging-factory"
logger.info(`${APPLICATION_NAME} JOBS listenting on port ${API_PORT}`)
/**
* See https://www.npmjs.com/package/cron.
*
* Most useful debugging option is `runOnInit: true`, which will immediately executes the job.
*
* Allowed fields
* # ┌────────────── second (optional)
* # │ ┌──────────── minute
* # │ │ ┌────────── hour
* # │ │ │ ┌──────── day of month
* # │ │ │ │ ┌────── month
* # │ │ │ │ │ ┌──── day of week
* # │ │ │ │ │ │
* # │ │ │ │ │ │
* # * * * * * *
*/
export async function enqueueJobs() {}
if (require.main === module) {
;(async () => {
logger.debug("Enqueuing jobs...")
try {
await enqueueJobs()
} catch {
logger.error("Failed to enqueue jobs!")
process.exit(1)
}
})()
}
+71
View File
@@ -0,0 +1,71 @@
# Serializers
Serializers take model data, and add or remove fields. They are used to convert a database representation of a model to a front-end representation of a model. This might include removing fields that should not be exposed to the front-end, or adding fields that are derived from the database representation.
Serializers are used in controllers to convert from a database representation to a front-end data packet. Serializers should not be used for general data formating such as date or money formatting, as formatting those kinds of things in the front-end is generally more flexible.
e.g. Usage in a Controller might look like this
Note that the BaseSerializer supports passing either an array or a single model to the `perform` method.
```typescript
import { isNil } from "lodash"
import logger from "@/utils/logger"
import { User } from "@/models"
import { UsersPolicy } from "@/policies"
import { CreateService } from "@/services/users"
import { IndexSerializer } from "@/serializers/users"
import BaseController from "@/controllers/base-controller"
export class FormsController extends BaseController {
async index() {
try {
const where = this.buildWhere()
const scopes = this.buildFilterScopes()
const scopedUsers = UsersPolicy.applyScope(scopes, this.currentUser)
const totalCount = await scopedUsers.count({ where })
const users = await scopedUsers.findAll({
where,
limit: this.pagination.limit,
offset: this.pagination.offset,
})
const serializedUsers = IndexSerializer.perform(users)
return this.response.json({
users: serializedUsers,
totalCount,
})
} catch (error) {
logger.error("Error fetching users" + error)
return this.response.status(400).json({
message: `Error fetching users: ${error}`,
})
}
}
async show() {
try {
const user = await this.loadUser()
if (isNil(user)) {
return this.response.status(404).json({
message: "User not found",
})
}
const policy = this.buildPolicy(user)
if (!policy.show()) {
return this.response.status(403).json({
message: "You are not authorized to view this user",
})
}
return this.response.json({ user, policy })
} catch (error) {
logger.error("Error fetching user" + error)
return this.response.status(400).json({
message: `Error fetching user: ${error}`,
})
}
}
}
```
+78
View File
@@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
type RemainingConstructorParameters<C extends new (...args: any[]) => any> = C extends new (
head: any,
...tail: infer TT
) => any
? TT
: []
/**
* BaseSerializer is a generic class that provides a common interface for all serializers.
* It is designed to be extended by other serializers, and provides a static `perform` method
* that can be used to serialize a single record or an array of records.
*
* The `perform` method is overloaded to handle both cases, and will return the serialized
* record or records based on the input. The return type is determined as the return type of the
* `perform` instance method of the subclass as a single value or an array of values.
*
* The `perform` takes its signature from the constructor of the subclass, while also allowing
* for an array of records to be passed in as the first argument.
*
* @param M The model type that the serializer is designed to handle
*
* @example
* class TableSerializer extends BaseSerializer<Dataset> {
* constructor(
* protected record: Dataset,
* protected currentUser: User
* ) {
* super(record)
* }
*
* perform(): DatasetTableView {}
* }
*
* TableSerializer.perform(dataset, currentUser) // => DatasetTableView
* TableSerializer.perform([dataset1, dataset2], currentUser) // => [DatasetTableView, DatasetTableView]
*/
export class BaseSerializer<Model> {
constructor(protected record: Model) {}
// Overload for handling a single record
static perform<T extends BaseSerializer<any>, C extends new (...args: any[]) => T>(
this: C,
...args: ConstructorParameters<C>
): ReturnType<InstanceType<C>["perform"]>
// Overload for handling an array of records
static perform<T extends BaseSerializer<any>, C extends new (...args: any[]) => T>(
this: C,
...args: [ConstructorParameters<C>[0][], ...RemainingConstructorParameters<C>]
): ReturnType<InstanceType<C>["perform"]>[]
// Implementation of the perform method
static perform<T extends BaseSerializer<any>, C extends new (...args: any[]) => T>(
this: C,
...args:
| ConstructorParameters<C>
| [ConstructorParameters<C>[0][], ...RemainingConstructorParameters<C>]
): ReturnType<InstanceType<C>["perform"]> | ReturnType<InstanceType<C>["perform"]>[] {
if (Array.isArray(args[0])) {
const records = args[0] as ConstructorParameters<C>[0][]
return records.map((record) => {
const instance = new this(record, ...args.slice(1))
return instance.perform()
}) as ReturnType<InstanceType<C>["perform"]>[]
} else {
const instance = new this(...args)
return instance.perform() as ReturnType<InstanceType<C>["perform"]>
}
}
perform(): any {
throw new Error("Not Implemented")
}
}
export default BaseSerializer
@@ -0,0 +1 @@
export { ShowSerializer } from "./show-serializer"
@@ -0,0 +1,28 @@
import { pick } from "lodash"
import { User } from "@/models"
import BaseSerializer from "@/serializers/base-serializer"
export type UserShowView = Pick<
User,
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
>
export class ShowSerializer extends BaseSerializer<User> {
perform(): UserShowView {
return {
...pick(this.record, [
"id",
"email",
"firstName",
"lastName",
"displayName",
"roles",
"createdAt",
"updatedAt",
]),
}
}
}
export default ShowSerializer
+2
View File
@@ -0,0 +1,2 @@
// Bundled exports
export * as Users from "./users"
@@ -0,0 +1,19 @@
import { pick } from "lodash"
import { User } from "@/models"
import BaseSerializer from "@/serializers/base-serializer"
export type UserIndexView = Pick<
User,
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles"
>
export class IndexSerializer extends BaseSerializer<User> {
perform(): UserIndexView {
return {
...pick(this.record, ["id", "email", "firstName", "lastName", "displayName", "roles"]),
}
}
}
export default IndexSerializer
+2
View File
@@ -0,0 +1,2 @@
export { ShowSerializer } from "./show-serializer"
export { IndexSerializer } from "./index-serializer"
@@ -0,0 +1,28 @@
import { pick } from "lodash"
import { User } from "@/models"
import BaseSerializer from "@/serializers/base-serializer"
export type UserShowView = Pick<
User,
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
>
export class ShowSerializer extends BaseSerializer<User> {
perform(): UserShowView {
return {
...pick(this.record, [
"id",
"email",
"firstName",
"lastName",
"displayName",
"roles",
"createdAt",
"updatedAt",
]),
}
}
}
export default ShowSerializer
+9
View File
@@ -0,0 +1,9 @@
import { API_PORT, APPLICATION_NAME } from "@/config"
import logger from "@/utils/logger"
import app from "@/app"
import { enqueueJobs } from "@/scheduler"
app.listen(API_PORT, async () => {
logger.info(`${APPLICATION_NAME} API listenting on port ${API_PORT}`)
await enqueueJobs()
})
+53
View File
@@ -0,0 +1,53 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HasNoArgsConstructor<T> = T extends { new (): any } ? true : false
type CleanConstructorParameters<T extends typeof BaseService> =
HasNoArgsConstructor<T> extends true ? [] : ConstructorParameters<T>
export class BaseService {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
constructor(...args: any[]) {}
static perform<T extends typeof BaseService>(
this: T,
...args: CleanConstructorParameters<T>
): ReturnType<InstanceType<T>["perform"]> {
const instance = new this(...args)
return instance.perform()
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
perform(): any {
throw new Error("Not Implemented")
}
}
export default BaseService
// Type Testing - keeping until I have real tests implemented
// class AsyncService extends BaseService {
// private param1: number
// constructor(param1: number) {
// super()
// this.param1 = param1
// }
// async perform(): Promise<string[]> {
// return ["async-string1", "async-string2"]
// }
// }
// class NonAsyncService extends BaseService {
// perform(): string {
// return "non-async-string"
// }
// }
// const param1 = 77
// AsyncService.perform(param1).then((result: string[]) => {
// logger.log(result)
// })
// const result = NonAsyncService.perform()
// logger.log(result)
+1
View File
@@ -0,0 +1 @@
export * as Users from "./users"
+53
View File
@@ -0,0 +1,53 @@
import { CreationAttributes } from "@sequelize/core"
import { isNil, random } from "lodash"
import { User } from "@/models"
import BaseService from "@/services/base-service"
export type UserCreationAttributes = Partial<CreationAttributes<User>>
export class CreateService extends BaseService {
constructor(private attributes: UserCreationAttributes) {
super()
}
async perform(): Promise<User> {
const { email, auth0Subject, roles, ...optionalAttributes } = this.attributes
if (isNil(email)) {
throw new Error("Email is required")
}
if (isNil(auth0Subject)) {
throw new Error("Auth0 Subject is required")
}
const [emailLocalPart] = email.split("@")
/**
* Yep, if we don't have enough data, your name becomes your email split randomly.
* This way we can at least have a first name and last name,
* and the first and last name are likely to be distinct.
*/
const randomSplit = random(1, emailLocalPart.length - 2)
const [firstNameFallback, lastNameFallback] = emailLocalPart.includes(".")
? emailLocalPart.split(".")
: [emailLocalPart.slice(0, randomSplit), emailLocalPart.slice(randomSplit)]
const { firstName, lastName } = optionalAttributes
const firstNameOrFallback = firstName || firstNameFallback
const lastNameOrFallback = lastName || lastNameFallback
const user = await User.create({
...optionalAttributes,
email,
auth0Subject: auth0Subject,
firstName: firstNameOrFallback,
lastName: lastNameOrFallback,
displayName: `${firstNameOrFallback} ${lastNameOrFallback}`,
roles: roles ?? [User.Roles.USER],
})
return user
}
}
export default CreateService
+14
View File
@@ -0,0 +1,14 @@
import { User } from "@/models"
import BaseService from "@/services/base-service"
export class DestroyService extends BaseService {
constructor(private user: User) {
super()
}
async perform() {
throw new Error("Not implemented")
}
}
export default DestroyService
@@ -0,0 +1,50 @@
import { auth0Integration } from "@/integrations"
import { User } from "@/models"
import { Op } from "@sequelize/core"
import BaseService from "@/services/base-service"
import { Users } from "@/services"
export class EnsureFromAuth0TokenService extends BaseService {
constructor(private token: string) {
super()
}
async perform(): Promise<User> {
const { auth0Subject, email, firstName, lastName } = await auth0Integration.getUserInfo(
this.token
)
const existingUser = await User.withScope(["asCurrentUser"]).findOne({
where: { auth0Subject },
})
if (existingUser) {
return existingUser
}
const firstTimeUser = await User.withScope(["asCurrentUser"]).findOne({
where: { [Op.or]: [{ auth0Subject: email }, { email: email }] },
})
if (firstTimeUser) {
await firstTimeUser.update({ auth0Subject })
return firstTimeUser
}
await Users.CreateService.perform({
auth0Subject,
email,
firstName,
lastName,
})
const newUser = await User.withScope(["asCurrentUser"]).findOne({
where: { auth0Subject },
rejectOnEmpty: true,
})
return newUser
}
}
export default EnsureFromAuth0TokenService
+6
View File
@@ -0,0 +1,6 @@
export { CreateService } from "./create-service"
export { UpdateService } from "./update-service"
export { DestroyService } from "./destroy-service"
// Special Services
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
+21
View File
@@ -0,0 +1,21 @@
import { Attributes } from "@sequelize/core"
import { User } from "@/models"
import BaseService from "@/services/base-service"
export type UserUpdateAttributes = Partial<Attributes<User>>
export class UpdateService extends BaseService {
constructor(
private user: User,
private attributes: UserUpdateAttributes
) {
super()
}
async perform(): Promise<User> {
return this.user.update(this.attributes)
}
}
export default UpdateService
+14
View File
@@ -0,0 +1,14 @@
export function acronymize(name: string) {
return name
.trim()
.split(/[\s-]+/g)
.filter((word) => word[0] === word[0].toUpperCase())
.map((word) => {
if (!isNaN(parseInt(word[0]))) return word
return word[0]
})
.join("")
}
export default acronymize
+11
View File
@@ -0,0 +1,11 @@
type AsArray<T> = T extends [] ? T : T[]
/**
* Wraps its argument in an array unless it is already an array (or array-like).
* See https://api.rubyonrails.org/classes/Array.html#method-c-wrap
*/
export function arrayWrap<T>(value: T | T[]): AsArray<T> {
return Array.isArray(value) ? (value as AsArray<T>) : ([value] as AsArray<T>)
}
export default arrayWrap
+24
View File
@@ -0,0 +1,24 @@
/**
* Converts a base64 data URL to a Buffer
* @param dataUrl - Base64 data URL string (e.g., "data:image/png;base64,iVBORw0KGgo...")
* @returns Buffer containing the binary data, or null if input is null/undefined
*/
export function base64ToBuffer(dataUrl: string | null | undefined): Buffer | null {
if (!dataUrl) {
return null
}
// Extract the base64 data from the data URL
// Format: data:image/png;base64,<base64-encoded-data>
const base64Match = dataUrl.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/)
if (!base64Match) {
// If it's not a data URL, assume it's already base64 encoded
return Buffer.from(dataUrl, "base64")
}
const base64Data = base64Match[2]
return Buffer.from(base64Data, "base64")
}
export default base64ToBuffer
+23
View File
@@ -0,0 +1,23 @@
/**
* Splits a string into chunks of a given size.
* Prefers first chunk to be the smallest, if string cannot be split evenly.
*
* e.g.
* chunkString("1234567890", 4) => ["12", "3456", "7890"]
*/
export function chunkString(string: string, chunkSize: number = 4): string[] {
const result = []
let currentIndex = string.length
// Loop from the end of the string and slice groups of chunkSize
while (currentIndex > 0) {
const start = Math.max(currentIndex - chunkSize, 0)
const chunk = string.slice(start, currentIndex)
result.unshift(chunk)
currentIndex -= chunkSize
}
return result
}
export default chunkString
@@ -0,0 +1,18 @@
/**
* Converts empty string values to null in an attributes object.
*
* When HTML form inputs are cleared, they send "" (empty string) which
* Sequelize rejects for numeric/decimal columns. This utility coerces
* those empty strings to null before the attributes reach the model.
*/
export function coerceEmptyStringsToNull<T extends Record<string, unknown>>(attributes: T): T {
const result: Record<string, unknown> = { ...attributes }
for (const key of Object.keys(result)) {
if (result[key] === "") {
result[key] = null
}
}
return result as T
}
export default coerceEmptyStringsToNull
+17
View File
@@ -0,0 +1,17 @@
/**
* Don't overuse this, it's not a full SQL parser.
* It's only purpose is to make SQL formatted by Sequelize 6 a bit more readable during development.
*/
export function compactSql(sql: string) {
const multiLineCommentPattern = /\/\*[\s\S]*?\*\//g
const singleLineCommentPattern = /--.*$/gm
const multiWhitespacePattern = /\s+/g
return sql
.replace(multiLineCommentPattern, "")
.replace(singleLineCommentPattern, "")
.replace(multiWhitespacePattern, " ")
.trim()
}
export default compactSql
+25
View File
@@ -0,0 +1,25 @@
import { has } from "lodash"
export function isCredentialFailure(error: unknown) {
return (
error instanceof Error &&
((has(error, "code") && error.code === "ELOGIN") ||
error.message.includes("Login failed for user"))
)
}
export function isSocketFailure(error: unknown) {
return error instanceof Error && has(error, "code") && error.code === "ESOCKET"
}
export function isMissingDatabaseFailure(error: unknown) {
return error instanceof Error && has(error, "code") && error.code === "3D000"
}
export function isNetworkFailure(error: unknown) {
return (
error instanceof Error &&
((has(error, "code") && error.code === "EAI_AGAIN") ||
error.message.includes("getaddrinfo EAI_AGAIN"))
)
}
+102
View File
@@ -0,0 +1,102 @@
import {
cloneDeep,
isArray,
isBoolean,
isNull,
isNumber,
isObject,
isString,
isUndefined,
} from "lodash"
export type Path =
| string
| {
[key: string]: (string | Path)[]
}
/*
Usage:
const object = {
a: 1,
b: 2,
c: {
d: 4,
f: 5,
},
g: [
{
h: 6,
i: 7,
},
{
h: 8,
i: 9,
}
],
}
const picked = deepPick(object, ["a", { c: ["d"] }, { g: ["h"] }]);
console.log(picked); // Output: { a: 1, c: { d: 4 }, g: [{ h: 6 }, { h: 8 }] }
TODO: figure out how to do this without "any"
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepPick(object: any, paths: Path[]): any {
if (isArray(object)) {
return object.map((item) => deepPick(item, paths))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return paths.reduce((result: any, path: Path) => {
if (isString(path)) {
if (path in object === false) return result
const value = cloneDeep(object[path])
if (isSimpleType(value)) {
result[path] = value
return result
} else if (isArray(value) && value.every(isSimpleType)) {
result[path] = value
return result
} else if (isArray(value) && value.every(isObject)) {
result[path] = []
return result
} else if (isObject(value)) {
result[path] = value
return result
} else {
throw new Error(`Unsupported value type at path: ${path} -> ${JSON.stringify(value)}`)
}
} else if (isObject(path)) {
Object.entries(path).forEach(([path, nestedPaths]) => {
if (path in object === false) return
const value = cloneDeep(object[path])
if (isSimpleType(value)) {
result[path] = value
} else if (isArray(value) && value.every(isSimpleType)) {
result[path] = value
} else if (Array.isArray(value) && value.every(isObject)) {
result[path] = value.map((item) => deepPick(item, nestedPaths))
} else if (isObject(value)) {
result[path] = deepPick(value, nestedPaths)
} else {
throw new Error(
`Unsupported value structure at path: ${path} -> ${JSON.stringify(value)}`
)
}
})
return result
} else {
throw new Error(`Unsupported path type: ${path}`)
}
}, {})
}
function isSimpleType(value: unknown) {
return (
isString(value) || isNumber(value) || isBoolean(value) || isNull(value) || isUndefined(value)
)
}
+16
View File
@@ -0,0 +1,16 @@
import { DateTime } from "luxon"
export function determineFiscalYear() {
const today = DateTime.local() // Get the current date
const fiscalYearStartMonth = 4 // Fiscal year starts in April
// If today's month is April or later, the fiscal year started this calendar year
if (today.month >= fiscalYearStartMonth) {
return today.year // Fiscal year is the current year
} else {
// If the month is before April, the fiscal year started last calendar year
return today.year - 1
}
}
export default determineFiscalYear
+17
View File
@@ -0,0 +1,17 @@
import qs from "qs"
export function enhancedQsDecoder(params: string) {
return qs.parse(params, {
strictNullHandling: true,
decoder(str, defaultDecoder, charset, type) {
if (type === "value") {
if (str === "true") return true
if (str === "false") return false
}
return defaultDecoder(str, defaultDecoder, charset)
},
})
}
export default enhancedQsDecoder
+13
View File
@@ -0,0 +1,13 @@
import { createLogger, format, transports } from "winston"
import { DEFAULT_LOG_LEVEL,} from "@/config"
export const consoleLogger = createLogger({
level: DEFAULT_LOG_LEVEL,
format: format.combine(format.colorize(), format.simple()),
transports: [new transports.Console()],
})
export const logger = consoleLogger
export default logger
+86
View File
@@ -0,0 +1,86 @@
import { isArray, isNil } from "lodash"
import { DateTime } from "luxon"
export function generateDiff<T>(
oldAttributes: T,
newAttributes: Partial<T>,
auditiableAttributes: Partial<T>
): string {
const diff = new Array<string>()
Object.keys(auditiableAttributes).forEach((key) => {
const oldValue = oldAttributes[key as keyof T]
const newValue = newAttributes[key as keyof T]
if (isArray(oldValue) || isArray(newValue)) {
const oVArray = (oldValue as unknown[]).join(", ")
const nVArray = (newValue as unknown[]).join(", ")
if (oVArray !== nVArray) {
diff.push(`${key}: '${oVArray}' => '${nVArray}'`)
}
} else if (!isNil(isDateTime(oldValue, newValue))) {
const res = isDateTime(oldValue, newValue)
if (res?.v1 !== res?.v2) {
diff.push(`${key}: '${res?.v1}' => '${res?.v2}'`)
}
} else if (typeof oldValue === "string" || typeof newValue === "string") {
if (oldValue !== newValue) {
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
}
} else if (typeof oldValue === "number" || typeof newValue === "number") {
if (oldValue !== newValue) {
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
}
} else if (oldValue !== newValue) {
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
}
})
if (diff.length === 0) return "No changes detected"
return diff.join("\n")
}
function isDateTime(
value1: unknown,
value2: unknown
): { v1: string | null; v2: string | null } | null {
let v1 = null as string | null
let v2 = null as string | null
if (typeof value1 === "undefined" || value1 === null) return null
try {
if (typeof value1 == "string") {
const v1Valid = DateTime.fromISO(value1).isValid
if (v1Valid) v1 = DateTime.fromISO(value1).toUTC().toISO()
}
if (typeof value1 == "object") {
const v1Valid = DateTime.fromJSDate(value1 as Date).isValid
if (v1Valid)
v1 = DateTime.fromJSDate(value1 as Date)
.toUTC()
.toISO()
}
if (typeof value2 == "string") {
const v1Valid = DateTime.fromISO(value2).isValid
if (v1Valid) v2 = DateTime.fromISO(value2).toUTC().toISO()
}
if (typeof value2 == "object") {
const v1Valid = DateTime.fromJSDate(value2 as Date).isValid
if (v1Valid)
v2 = DateTime.fromJSDate(value2 as Date)
.toUTC()
.toISO()
}
if (!isNil(v1) || !isNil(v2)) return { v1, v2 }
return null
} catch (_error) {
return null
}
}
@@ -0,0 +1,52 @@
import {
AttributeNames,
Attributes,
FindOptions,
Model,
Op,
WhereOptions,
sql,
where,
} from "@sequelize/core"
import arrayWrap from "@/utils/array-wrap"
/**
* Generates a search scope for Sequelize models that allows for custom SQL conditions per term.
*/
export function searchFieldsByTermsFactory<M extends Model>(
fields: AttributeNames<M>[]
): (termOrTerms: string | string[]) => FindOptions<Attributes<M>> {
return (termOrTerms: string | string[]): FindOptions<Attributes<M>> => {
const terms = arrayWrap(termOrTerms)
if (terms.length === 0) {
return {}
}
// TODO: rebuild as successive scope calls once
// https://github.com/sequelize/sequelize/issues/17304 is fixed
// (we would no longer need the and operator in the where clause)
const whereQuery: {
[Op.and]?: WhereOptions<M>[]
} = {}
const whereConditions: WhereOptions<M>[] = terms.map((term: string) => {
const termPattern = `%${term.toLowerCase()}%`
const fieldsQuery = fields.map((field) => {
return where(sql.fn("LOWER", sql.attribute(field)), Op.like, termPattern)
})
return {
[Op.or]: fieldsQuery,
}
})
whereQuery[Op.and] = whereConditions
return {
where: whereQuery,
}
}
}
export default searchFieldsByTermsFactory
+5
View File
@@ -0,0 +1,5 @@
export function sleep(seconds: number) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
export default sleep
+3
View File
@@ -0,0 +1,3 @@
export function stripTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url
}
+13
View File
@@ -0,0 +1,13 @@
import { last } from "lodash"
export function toSentence(items: string[]): string {
if (items.length === 0) return ""
if (items.length === 1) return items[0]
if (items.length === 2) return items.join(" and ")
const itemsExceptLast = items.slice(0, -1).join(", ")
const lastItem = last(items)
return `${itemsExceptLast}, and ${lastItem}`
}
export default toSentence
+61
View File
@@ -0,0 +1,61 @@
import logger from "@/utils/logger"
/**
* Wraps an async function with logging for start, completion, and errors.
* Accepts positional parameters like findEach.
*/
function withLoggingFactory(
description: string,
wrappedFunction: () => Promise<void>
): () => Promise<void>
function withLoggingFactory<T extends Record<string, unknown>>(
description: string,
context: T,
wrappedFunction: (context: T) => Promise<void>
): () => Promise<void>
function withLoggingFactory<T extends Record<string, unknown>>(
description: string,
contextOrFunction?: T | (() => Promise<void>),
wrappedFunction?: (context: T) => Promise<void>
): () => Promise<void> {
// 2-argument version: (description, wrappedFunction)
if (typeof contextOrFunction === "function") {
return async () => {
logger.info(`Starting: ${description}`)
try {
await contextOrFunction()
logger.info(`Completed: ${description}`)
} catch (error) {
logger.error(`Failed: ${description}`, { error })
throw error
}
}
}
// 3-argument version: (description, context, wrappedFunction)
if (typeof contextOrFunction !== "object") {
throw new Error("Missing context")
}
const context: T = contextOrFunction
if (typeof wrappedFunction !== "function") {
throw new Error("Missing wrapped function")
}
return async () => {
logger.info(`Starting: ${description} with ${context}`, { context })
try {
await wrappedFunction(context)
logger.info(`Completed: ${description} with ${context}`, { context })
} catch (error) {
logger.error(`Failed: ${description} with ${context}`, { context, error })
throw error
}
}
}
export default withLoggingFactory
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>404 Not Found</title>
<style>
body {
text-align: center;
padding-top: 100px;
}
hr {
margin-top: 30px;
margin-bottom: 30px;
}
</style>
</head>
<body>
<h1>Error 404</h1>
<p>Oops! The page you're looking doesn't exist.</p>
<hr />
<p>Site: ${applicationName}</p>
<p>Version: ${releaseTag}</p>
<p>Commit Hash: ${gitCommitHash}</p>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>404 Not Found</title>
<style>
body {
text-align: center;
padding-top: 100px;
}
hr {
margin-top: 30px;
margin-bottom: 30px;
}
</style>
</head>
<body>
<h1>Error 404</h1>
<p>This is a stub is meant to be replaced by the compiled front-end in production</p>
<hr />
<p>Site: Guardian</p>
<p>Version: 0.0.1</p>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
# API service Tests
## Implementation
Tests are written in [vitest](https://vitest.dev/guide/)
Test initialization goes like this:
1. `api/vitest.config.mts` loads the ts config and finds the appropriate setup functions.
2. Before running the tests, it runs the `globalSetup` function from `api/tests/global-setup.ts`. This does things like setting up the database and running migrations and base seeds.
3. Next it loads a specific test file triggers the `setupFiles` files, currently only `api/tests/setup.ts`. These setup files add callbacks that will run before/after _each test file_ runs, so they should be performant. Mostly cleanup functions.
4. It runs the actual tests in the loaded file.
5. (Currently) Runs `beforeEach` callback that cleans the database before each test file is run.
6. Runs the next test file, and repeats from step 3.
## General Notes About Tests
1. Tests should map to a specific file in the api/src folder.
e.g.
- `api/src/models/funding-submission-line-json.ts` maps to `api/tests/models/funding-submission-line-json.test.ts`
- `api/src/services/centre-services.ts` maps to `api/tests/services/centre-services.test.ts`
2. Tests should follow the naming convention `{filename}.test.{extension}`.
3. Test file location should be moved if a given file is moved, and deleted if the file under test is deleted.
4. A good general pattern for a test is
```typescript
describe("api/src/services/centre-services.ts", () => { // references file under test
describe("CentreServices", () => { // references class or model under test
describe(".create", () => { // referneces a specific method on the class or model
test("creates a new centre in the database", async () => { // descriptive message about the specific behaviour under test
})
})
})
```
5. I'm using a plugin that lets me switch between the test and non-test file, and creates the test file if it does not exist. It's not great, but it mostly works. See <https://marketplace.visualstudio.com/items?itemName=klondikemarlen.create-test-file>
It requires this config (in your workspace or `.vscode/settings.json`).
> Note that if this is in your worspace config must be inside the "settings" entry. i.e. `{ "settings": { // these settings } }`.
```json
{
"createTestFile.nameTemplate": "{filename}.test.{extension}",
"createTestFile.languages": {
"[vue]": {
"createTestFile.nameTemplate": "{filename}.test.{extension}.ts"
}
},
"createTestFile.pathMaps": [
{
// Other examples
// "pathPattern": "/?(.*)",
// "testFilePathPattern": "spec/$1"
"pathPattern": "(api)/src/?(.*)",
"testFilePathPattern": "$1/tests/$2"
},
{
"pathPattern": "(web)/src/?(.*)",
"testFilePathPattern": "$1/tests/$2"
}
],
"createTestFile.isTestFileMatchers": [
"^(?:test|spec)s?/",
"/(?:test|spec)s?/",
"/?(?:test|spec)s?_",
"/?_(?:test|spec)s?",
"/?\\.(?:test|spec)s?",
"/?(?:test|spec)s?\\."
]
}
```
@@ -0,0 +1,25 @@
import { User } from "@/models"
import { userFactory } from "@/factories"
import { mockCurrentUser, request } from "@/support"
describe("api/src/controllers/current-user-controller.ts", () => {
describe("CurrentUserController", () => {
describe("#show", () => {
test("it returns the policy alongside the user", async () => {
// Arrange
const currentUser = await userFactory.create({
roles: [User.Roles.SYSTEM_ADMIN],
})
mockCurrentUser(currentUser)
// Act
const response = await request().get("/api/current-user")
// Assert
expect(response.status).toBe(200)
expect(response.body.policy).toBeDefined()
})
})
})
})
@@ -0,0 +1,32 @@
import { User } from "@/models"
import { userFactory } from "@/factories"
import { mockCurrentUser, request } from "@/support"
describe("api/src/controllers/users-controller.ts", () => {
beforeEach(async () => {
const currentUser = await userFactory.create({
roles: [User.Roles.SYSTEM_ADMIN],
})
mockCurrentUser(currentUser)
})
describe("UsersController", () => {
describe("#create", () => {
test("when creating a new user as a system admin, it creates the user", async () => {
// Arrange
const attributes = {
email: "test_create@example.com",
auth0Subject: "test_create@example.com",
}
// Act
const response = await request().post("/api/users").send(attributes)
// Assert
expect(response.status).toBe(201)
expect(response.body.user.email).toEqual(attributes.email)
})
})
})
})

Some files were not shown because too many files have changed in this diff Show More