generated from alphane/template
Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
// Controllers
|
||||
export { CurrentUserController } from "./current-user-controller"
|
||||
export { UsersController } from "./users-controller"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})()
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
auth0Integration,
|
||||
Auth0PayloadError,
|
||||
type Auth0UserInfo,
|
||||
} from "./auth0-integration"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# api/src/middlewares/README.md
|
||||
|
||||
Middleware are actions that run before or after every request.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type NextFunction, type Response } from "express"
|
||||
import { type Request as JwtRequest } from "express-jwt"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import { logger } from "@/utils/logger"
|
||||
import { Auth0PayloadError } from "@/integrations/auth0-integration"
|
||||
import { User } from "@/models"
|
||||
import { Users } from "@/services"
|
||||
|
||||
export type AuthorizationRequest = JwtRequest & {
|
||||
currentUser?: User | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires api/src/middlewares/jwt-middleware.ts to be run first
|
||||
*
|
||||
* I'd love to merge that code in here at some point, or make all this code a controller "before action"
|
||||
* I'm uncomfortable with creating users automatically here, I'd rather the front-end requested
|
||||
* user creation directly, and might switch to that in the future.
|
||||
*
|
||||
* NOTE: must be kept in sync with api/tests/support/mock-current-user.ts
|
||||
*/
|
||||
export async function authorizationMiddleware(
|
||||
req: AuthorizationRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const user = await User.withScope(["asCurrentUser"]).findOne({
|
||||
where: {
|
||||
auth0Subject: req.auth?.sub || "",
|
||||
},
|
||||
})
|
||||
|
||||
if (!isNil(user)) {
|
||||
req.currentUser = user
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.headers.authorization || ""
|
||||
const user = await Users.EnsureFromAuth0TokenService.perform(token)
|
||||
req.currentUser = user
|
||||
return next()
|
||||
} catch (error) {
|
||||
logger.error(`Error ensuring user from Auth0 token ${error}`, { error })
|
||||
|
||||
if (error instanceof Auth0PayloadError) {
|
||||
return res.status(502).json({ message: "External authorization api failed." })
|
||||
} else {
|
||||
return res.status(401).json({ message: "User authentication failed." })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { authorizationMiddleware } from "./authorization-middleware"
|
||||
export { jwtMiddleware } from "./jwt-middleware"
|
||||
export { requestLoggerMiddleware } from "./request-logger-middleware"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
// Policy Bundles
|
||||
export { type BaseScopeOptions } from "./base-policy"
|
||||
export { UsersPolicy } from "./users-policy"
|
||||
@@ -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
|
||||
@@ -0,0 +1,91 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
import {
|
||||
Router,
|
||||
type Request,
|
||||
type Response,
|
||||
type ErrorRequestHandler,
|
||||
type NextFunction,
|
||||
} from "express"
|
||||
import { UnauthorizedError } from "express-jwt"
|
||||
import { template } from "lodash"
|
||||
|
||||
import { APPLICATION_NAME, GIT_COMMIT_HASH, NODE_ENV, RELEASE_TAG } from "@/config"
|
||||
import { logger } from "@/utils/logger"
|
||||
|
||||
import { jwtMiddleware, authorizationMiddleware } from "@/middlewares"
|
||||
|
||||
import { CurrentUserController, UsersController } from "@/controllers"
|
||||
|
||||
export const router = Router()
|
||||
|
||||
// non-api (no authentication is required) routes
|
||||
router.route("/_status").get((_req: Request, res: Response) => {
|
||||
return res.json({
|
||||
RELEASE_TAG,
|
||||
GIT_COMMIT_HASH,
|
||||
})
|
||||
})
|
||||
|
||||
// external (public) routes - no authentication required
|
||||
|
||||
// api routes
|
||||
router.use("/api", jwtMiddleware, authorizationMiddleware)
|
||||
|
||||
router.route("/api/current-user").get(CurrentUserController.show)
|
||||
|
||||
router.route("/api/users").get(UsersController.index).post(UsersController.create)
|
||||
router
|
||||
.route("/api/users/:userId")
|
||||
.get(UsersController.show)
|
||||
.patch(UsersController.update)
|
||||
.delete(UsersController.destroy)
|
||||
|
||||
// if no other routes match, return a 404
|
||||
router.use("/api", (req: Request, res: Response) => {
|
||||
return res.status(404).json({ message: "Not Found", url: req.path })
|
||||
})
|
||||
|
||||
// Special error handler for all api errors
|
||||
// See https://expressjs.com/en/guide/error-handling.html#writing-error-handlers
|
||||
router.use("/api", (err: ErrorRequestHandler, _req: Request, res: Response, next: NextFunction) => {
|
||||
if (res.headersSent) {
|
||||
return next(err)
|
||||
}
|
||||
|
||||
if (err instanceof UnauthorizedError) {
|
||||
logger.error(err)
|
||||
return res.status(err.status).json({ message: err.inner.message })
|
||||
}
|
||||
|
||||
/* if (err instanceof DatabaseError) {
|
||||
logger.error(err)
|
||||
return res.status(422).json({ message: "Invalid query against database." })
|
||||
}
|
||||
*/
|
||||
logger.error(err)
|
||||
return res.status(500).json({ message: "Internal Server Error" })
|
||||
})
|
||||
|
||||
// if no other non-api routes match, send the pretty 404 page
|
||||
if (NODE_ENV == "development") {
|
||||
router.use("/", (_req: Request, res: Response) => {
|
||||
const templatePath = path.resolve(__dirname, "web/404.html")
|
||||
try {
|
||||
const templateString = fs.readFileSync(templatePath, "utf8")
|
||||
const compiledTemplate = template(templateString)
|
||||
const result = compiledTemplate({
|
||||
applicationName: APPLICATION_NAME,
|
||||
releaseTag: RELEASE_TAG,
|
||||
gitCommitHash: GIT_COMMIT_HASH,
|
||||
})
|
||||
return res.status(404).send(result)
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return res.status(500).send(`Error building 404 page: ${error}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
export * as Users from "./users"
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
import { User } from "@/models"
|
||||
import BaseService from "@/services/base-service"
|
||||
|
||||
export class DestroyService extends BaseService {
|
||||
constructor(private user: User) {
|
||||
super()
|
||||
}
|
||||
|
||||
async perform() {
|
||||
throw new Error("Not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
export default DestroyService
|
||||
@@ -0,0 +1,50 @@
|
||||
import { auth0Integration } from "@/integrations"
|
||||
import { User } from "@/models"
|
||||
import { Op } from "@sequelize/core"
|
||||
import BaseService from "@/services/base-service"
|
||||
import { Users } from "@/services"
|
||||
|
||||
export class EnsureFromAuth0TokenService extends BaseService {
|
||||
constructor(private token: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
async perform(): Promise<User> {
|
||||
const { auth0Subject, email, firstName, lastName } = await auth0Integration.getUserInfo(
|
||||
this.token
|
||||
)
|
||||
|
||||
const existingUser = await User.withScope(["asCurrentUser"]).findOne({
|
||||
where: { auth0Subject },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return existingUser
|
||||
}
|
||||
|
||||
const firstTimeUser = await User.withScope(["asCurrentUser"]).findOne({
|
||||
where: { [Op.or]: [{ auth0Subject: email }, { email: email }] },
|
||||
})
|
||||
|
||||
if (firstTimeUser) {
|
||||
await firstTimeUser.update({ auth0Subject })
|
||||
return firstTimeUser
|
||||
}
|
||||
|
||||
await Users.CreateService.perform({
|
||||
auth0Subject,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
})
|
||||
|
||||
const newUser = await User.withScope(["asCurrentUser"]).findOne({
|
||||
where: { auth0Subject },
|
||||
rejectOnEmpty: true,
|
||||
})
|
||||
|
||||
return newUser
|
||||
}
|
||||
}
|
||||
|
||||
export default EnsureFromAuth0TokenService
|
||||
@@ -0,0 +1,6 @@
|
||||
export { CreateService } from "./create-service"
|
||||
export { UpdateService } from "./update-service"
|
||||
export { DestroyService } from "./destroy-service"
|
||||
|
||||
// Special Services
|
||||
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
export function sleep(seconds: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
|
||||
export default sleep
|
||||
@@ -0,0 +1,3 @@
|
||||
export function stripTrailingSlash(url: string) {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user