templating api

This commit is contained in:
2026-06-19 22:20:43 -07:00
parent 08d7a80f56
commit 84f894c356
110 changed files with 12432 additions and 0 deletions
+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 findAndAuthorizeCurrentUserMiddleware(
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.FindFromAuth0TokenService.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 { findAndAuthorizeCurrentUserMiddleware } from "./find-and-authorize-current-user-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, findAndAuthorizeCurrentUserMiddleware } 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, findAndAuthorizeCurrentUserMiddleware)
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,35 @@
import { auth0Integration } from "@/integrations"
import { User } from "@/models"
import { Op } from "@sequelize/core"
import BaseService from "@/services/base-service"
export class FindFromAuth0TokenService extends BaseService {
constructor(private token: string) {
super()
}
async perform(): Promise<User> {
const { auth0Subject, email } = 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
}
throw new Error("No user found for this token.")
}
}
export default FindFromAuth0TokenService
+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 { FindFromAuth0TokenService } from "./find-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>