Initial commit

This commit is contained in:
2026-06-24 23:47:55 -07:00
commit d134b480a0
297 changed files with 30726 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
import {
AttributeNames,
Attributes,
CreationOptional,
FindOptions,
Model,
ModelStatic,
Op,
WhereOptions,
} from "@sequelize/core"
import { searchFieldsByTermsFactory } from "@/utils/search-fields-by-terms-factory"
// See api/node_modules/@sequelize/core/lib/model.d.ts -> Model
export abstract class BaseModel<
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
TModelAttributes extends {} = any,
// eslint-disable-next-line @typescript-eslint/ban-types
TCreationAttributes extends {} = TModelAttributes,
> extends Model<TModelAttributes, TCreationAttributes> {
declare id: CreationOptional<number>
static addSearchScope<M extends BaseModel>(this: ModelStatic<M>, fields: AttributeNames<M>[]) {
const searchScopeFunction = searchFieldsByTermsFactory<M>(fields)
this.addScope("search", searchScopeFunction)
}
// static findByPk<M extends Model, R = Attributes<M>>(
// this: ModelStatic<M>,
// identifier: unknown,
// options: FindByPkOptions<M> & { raw: true; rejectOnEmpty?: false },
// ): Promise<R | null>;
// static findByPk<M extends Model, R = Attributes<M>>(
// this: ModelStatic<M>,
// identifier: unknown,
// options: NonNullFindByPkOptions<M> & { raw: true },
// ): Promise<R>;
// static findByPk<M extends Model>(
// this: ModelStatic<M>,
// identifier: unknown,
// options: NonNullFindByPkOptions<M>,
// ): Promise<M>;
// static findByPk<M extends Model>(
// this: ModelStatic<M>,
// identifier: unknown,
// options?: FindByPkOptions<M>,
// ): Promise<M | null>;
public static async findByIdentifierOrPk<M extends BaseModel>(
this: ModelStatic<M>,
identifierOrPk: string | number,
options?: Omit<FindOptions<Attributes<M>>, "where">
): Promise<M | null> {
if (typeof identifierOrPk === "number" || !isNaN(Number(identifierOrPk))) {
const primaryKey = identifierOrPk
return this.findByPk(primaryKey, options)
}
const identifier = identifierOrPk
if (!("identifier" in this.getAttributes())) {
throw new Error(`${this.name} does not have a 'identifier' attribute.`)
}
return this.findOne({
...options,
// @ts-expect-error - We know that the model has a slug attribute, and are ignoring the TS error
where: { identifier },
})
}
// See api/node_modules/@sequelize/core/lib/model.d.ts -> findAll
// Taken from https://api.rubyonrails.org/v7.1.0/classes/ActiveRecord/Batches.html#method-i-find_each
// Enforces sort by id, overwriting any supplied order
public static async findEach<M extends BaseModel>(
this: ModelStatic<M>,
processFunction: (record: M) => Promise<void>
): Promise<void>
public static async findEach<M extends BaseModel, R = Attributes<M>>(
this: ModelStatic<M>,
options: Omit<FindOptions<Attributes<M>>, "raw"> & {
raw: true
batchSize?: number
},
processFunction: (record: R) => Promise<void>
): Promise<void>
public static async findEach<M extends BaseModel>(
this: ModelStatic<M>,
options: FindOptions<Attributes<M>> & {
batchSize?: number
},
processFunction: (record: M) => Promise<void>
): Promise<void>
public static async findEach<M extends BaseModel, R = Attributes<M>>(
this: ModelStatic<M>,
optionsOrFunction:
| ((record: M) => Promise<void>)
| (Omit<FindOptions<Attributes<M>>, "raw"> & { raw: true; batchSize?: number })
| (FindOptions<Attributes<M>> & { batchSize?: number }),
maybeFunction?: (record: R | M) => Promise<void>
): Promise<void> {
let options:
| (FindOptions<Attributes<M>> & { batchSize?: number })
| (Omit<FindOptions<Attributes<M>>, "raw"> & { raw: true; batchSize?: number })
// TODO: fix types so that process function is M when not raw
// and R when raw. Raw is usable, just incorrectly typed.
let processFunction: (record: M) => Promise<void>
if (typeof optionsOrFunction === "function") {
options = {}
processFunction = optionsOrFunction
} else if (maybeFunction === undefined) {
throw new Error("findEach requires a processFunction")
} else {
options = optionsOrFunction
processFunction = maybeFunction
}
const batchSize = options.batchSize ?? 1000
let lastId = 0
let continueProcessing = true
while (continueProcessing) {
// TODO: fix where option types so cast is not needed
const whereClause = {
...options.where,
id: { [Op.gt]: lastId },
} as WhereOptions<Attributes<M>>
const records = await this.findAll({
...options,
where: whereClause,
limit: batchSize,
order: [["id", "ASC"]],
})
for (const record of records) {
await processFunction(record)
lastId = record.id
}
if (records.length < batchSize) {
continueProcessing = false
}
}
}
}
export default BaseModel
+19
View File
@@ -0,0 +1,19 @@
import db from "@/db/db-client"
// Models
import User, { UserRoles } from "@/models/user"
db.addModels([
User,
])
// Lazy load scopes
User.establishScopes()
export {
User,
UserRoles,
}
// Special db instance will all models loaded
export default db
+110
View File
@@ -0,0 +1,110 @@
import {
type CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
type NonAttribute,
sql,
} from "@sequelize/core"
import {
Attribute,
AutoIncrement,
Default,
Index,
NotNull,
PrimaryKey,
ValidateAttribute,
} from "@sequelize/core/decorators-legacy"
import { isArray, isNil } from "lodash"
import BaseModel from "@/models/base-model"
/** Keep in sync with web/src/api/users-api.ts */
export enum UserRoles {
SYSTEM_ADMIN = "system_admin",
USER = "user",
}
export class User extends BaseModel<InferAttributes<User>, InferCreationAttributes<User>> {
static readonly Roles = UserRoles
@Attribute(DataTypes.INTEGER)
@PrimaryKey
@AutoIncrement
declare id: CreationOptional<number>
@Attribute(DataTypes.STRING(100))
@NotNull
@Index({ unique: true })
declare email: string
@Attribute(DataTypes.STRING(100))
@NotNull
@Index({ unique: true })
declare auth0Subject: string
@Attribute(DataTypes.STRING(100))
@NotNull
declare firstName: string
@Attribute(DataTypes.STRING(100))
@NotNull
declare lastName: string
@Attribute(DataTypes.STRING(200))
@NotNull
declare displayName: string
@Attribute({
type: DataTypes.STRING(255),
get() {
const roles = this.getDataValue("roles")
if (isNil(roles)) {
return []
}
return roles.split(",")
},
set(value: string[]) {
this.setDataValue("roles", value.join(","))
},
})
@NotNull
@ValidateAttribute({
validator: (valueString: string | string[]) => {
const value = isArray(valueString) ? valueString : valueString.split(",")
const validRoles = Object.values(UserRoles) as string[]
const invalidRoles = value.filter((role) => !validRoles.includes(role))
if (invalidRoles.length > 0) throw new Error(`Invalid role: ${invalidRoles.join(", ")}`)
},
})
declare roles: UserRoles[]
@Attribute(DataTypes.DATE(0))
@NotNull
@Default(sql.literal("CURRENT_TIMESTAMP"))
declare createdAt: CreationOptional<Date>
@Attribute(DataTypes.DATE(0))
@NotNull
@Default(sql.literal("CURRENT_TIMESTAMP"))
declare updatedAt: CreationOptional<Date>
@Attribute(DataTypes.DATE(0))
declare deletedAt: Date | null
// Magic Attributes
get isSystemAdmin(): NonAttribute<boolean> {
return this.roles.some((role) => role === UserRoles.SYSTEM_ADMIN)
}
// Associations
// Scopes
static establishScopes(): void {
this.addSearchScope(["firstName", "lastName", "displayName", "email"])
this.addScope("asCurrentUser", {})
}
}
export default User