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
+14
View File
@@ -0,0 +1,14 @@
export function acronymize(name: string) {
return name
.trim()
.split(/[\s-]+/g)
.filter((word) => word[0] === word[0].toUpperCase())
.map((word) => {
if (!isNaN(parseInt(word[0]))) return word
return word[0]
})
.join("")
}
export default acronymize
+11
View File
@@ -0,0 +1,11 @@
type AsArray<T> = T extends [] ? T : T[]
/**
* Wraps its argument in an array unless it is already an array (or array-like).
* See https://api.rubyonrails.org/classes/Array.html#method-c-wrap
*/
export function arrayWrap<T>(value: T | T[]): AsArray<T> {
return Array.isArray(value) ? (value as AsArray<T>) : ([value] as AsArray<T>)
}
export default arrayWrap
+24
View File
@@ -0,0 +1,24 @@
/**
* Converts a base64 data URL to a Buffer
* @param dataUrl - Base64 data URL string (e.g., "data:image/png;base64,iVBORw0KGgo...")
* @returns Buffer containing the binary data, or null if input is null/undefined
*/
export function base64ToBuffer(dataUrl: string | null | undefined): Buffer | null {
if (!dataUrl) {
return null
}
// Extract the base64 data from the data URL
// Format: data:image/png;base64,<base64-encoded-data>
const base64Match = dataUrl.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/)
if (!base64Match) {
// If it's not a data URL, assume it's already base64 encoded
return Buffer.from(dataUrl, "base64")
}
const base64Data = base64Match[2]
return Buffer.from(base64Data, "base64")
}
export default base64ToBuffer
+23
View File
@@ -0,0 +1,23 @@
/**
* Splits a string into chunks of a given size.
* Prefers first chunk to be the smallest, if string cannot be split evenly.
*
* e.g.
* chunkString("1234567890", 4) => ["12", "3456", "7890"]
*/
export function chunkString(string: string, chunkSize: number = 4): string[] {
const result = []
let currentIndex = string.length
// Loop from the end of the string and slice groups of chunkSize
while (currentIndex > 0) {
const start = Math.max(currentIndex - chunkSize, 0)
const chunk = string.slice(start, currentIndex)
result.unshift(chunk)
currentIndex -= chunkSize
}
return result
}
export default chunkString
@@ -0,0 +1,18 @@
/**
* Converts empty string values to null in an attributes object.
*
* When HTML form inputs are cleared, they send "" (empty string) which
* Sequelize rejects for numeric/decimal columns. This utility coerces
* those empty strings to null before the attributes reach the model.
*/
export function coerceEmptyStringsToNull<T extends Record<string, unknown>>(attributes: T): T {
const result: Record<string, unknown> = { ...attributes }
for (const key of Object.keys(result)) {
if (result[key] === "") {
result[key] = null
}
}
return result as T
}
export default coerceEmptyStringsToNull
+17
View File
@@ -0,0 +1,17 @@
/**
* Don't overuse this, it's not a full SQL parser.
* It's only purpose is to make SQL formatted by Sequelize 6 a bit more readable during development.
*/
export function compactSql(sql: string) {
const multiLineCommentPattern = /\/\*[\s\S]*?\*\//g
const singleLineCommentPattern = /--.*$/gm
const multiWhitespacePattern = /\s+/g
return sql
.replace(multiLineCommentPattern, "")
.replace(singleLineCommentPattern, "")
.replace(multiWhitespacePattern, " ")
.trim()
}
export default compactSql
+25
View File
@@ -0,0 +1,25 @@
import { has } from "lodash"
export function isCredentialFailure(error: unknown) {
return (
error instanceof Error &&
((has(error, "code") && error.code === "ELOGIN") ||
error.message.includes("Login failed for user"))
)
}
export function isSocketFailure(error: unknown) {
return error instanceof Error && has(error, "code") && error.code === "ESOCKET"
}
export function isMissingDatabaseFailure(error: unknown) {
return error instanceof Error && has(error, "code") && error.code === "3D000"
}
export function isNetworkFailure(error: unknown) {
return (
error instanceof Error &&
((has(error, "code") && error.code === "EAI_AGAIN") ||
error.message.includes("getaddrinfo EAI_AGAIN"))
)
}
+102
View File
@@ -0,0 +1,102 @@
import {
cloneDeep,
isArray,
isBoolean,
isNull,
isNumber,
isObject,
isString,
isUndefined,
} from "lodash"
export type Path =
| string
| {
[key: string]: (string | Path)[]
}
/*
Usage:
const object = {
a: 1,
b: 2,
c: {
d: 4,
f: 5,
},
g: [
{
h: 6,
i: 7,
},
{
h: 8,
i: 9,
}
],
}
const picked = deepPick(object, ["a", { c: ["d"] }, { g: ["h"] }]);
console.log(picked); // Output: { a: 1, c: { d: 4 }, g: [{ h: 6 }, { h: 8 }] }
TODO: figure out how to do this without "any"
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepPick(object: any, paths: Path[]): any {
if (isArray(object)) {
return object.map((item) => deepPick(item, paths))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return paths.reduce((result: any, path: Path) => {
if (isString(path)) {
if (path in object === false) return result
const value = cloneDeep(object[path])
if (isSimpleType(value)) {
result[path] = value
return result
} else if (isArray(value) && value.every(isSimpleType)) {
result[path] = value
return result
} else if (isArray(value) && value.every(isObject)) {
result[path] = []
return result
} else if (isObject(value)) {
result[path] = value
return result
} else {
throw new Error(`Unsupported value type at path: ${path} -> ${JSON.stringify(value)}`)
}
} else if (isObject(path)) {
Object.entries(path).forEach(([path, nestedPaths]) => {
if (path in object === false) return
const value = cloneDeep(object[path])
if (isSimpleType(value)) {
result[path] = value
} else if (isArray(value) && value.every(isSimpleType)) {
result[path] = value
} else if (Array.isArray(value) && value.every(isObject)) {
result[path] = value.map((item) => deepPick(item, nestedPaths))
} else if (isObject(value)) {
result[path] = deepPick(value, nestedPaths)
} else {
throw new Error(
`Unsupported value structure at path: ${path} -> ${JSON.stringify(value)}`
)
}
})
return result
} else {
throw new Error(`Unsupported path type: ${path}`)
}
}, {})
}
function isSimpleType(value: unknown) {
return (
isString(value) || isNumber(value) || isBoolean(value) || isNull(value) || isUndefined(value)
)
}
+16
View File
@@ -0,0 +1,16 @@
import { DateTime } from "luxon"
export function determineFiscalYear() {
const today = DateTime.local() // Get the current date
const fiscalYearStartMonth = 4 // Fiscal year starts in April
// If today's month is April or later, the fiscal year started this calendar year
if (today.month >= fiscalYearStartMonth) {
return today.year // Fiscal year is the current year
} else {
// If the month is before April, the fiscal year started last calendar year
return today.year - 1
}
}
export default determineFiscalYear
+17
View File
@@ -0,0 +1,17 @@
import qs from "qs"
export function enhancedQsDecoder(params: string) {
return qs.parse(params, {
strictNullHandling: true,
decoder(str, defaultDecoder, charset, type) {
if (type === "value") {
if (str === "true") return true
if (str === "false") return false
}
return defaultDecoder(str, defaultDecoder, charset)
},
})
}
export default enhancedQsDecoder
+13
View File
@@ -0,0 +1,13 @@
import { createLogger, format, transports } from "winston"
import { DEFAULT_LOG_LEVEL,} from "@/config"
export const consoleLogger = createLogger({
level: DEFAULT_LOG_LEVEL,
format: format.combine(format.colorize(), format.simple()),
transports: [new transports.Console()],
})
export const logger = consoleLogger
export default logger
+86
View File
@@ -0,0 +1,86 @@
import { isArray, isNil } from "lodash"
import { DateTime } from "luxon"
export function generateDiff<T>(
oldAttributes: T,
newAttributes: Partial<T>,
auditiableAttributes: Partial<T>
): string {
const diff = new Array<string>()
Object.keys(auditiableAttributes).forEach((key) => {
const oldValue = oldAttributes[key as keyof T]
const newValue = newAttributes[key as keyof T]
if (isArray(oldValue) || isArray(newValue)) {
const oVArray = (oldValue as unknown[]).join(", ")
const nVArray = (newValue as unknown[]).join(", ")
if (oVArray !== nVArray) {
diff.push(`${key}: '${oVArray}' => '${nVArray}'`)
}
} else if (!isNil(isDateTime(oldValue, newValue))) {
const res = isDateTime(oldValue, newValue)
if (res?.v1 !== res?.v2) {
diff.push(`${key}: '${res?.v1}' => '${res?.v2}'`)
}
} else if (typeof oldValue === "string" || typeof newValue === "string") {
if (oldValue !== newValue) {
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
}
} else if (typeof oldValue === "number" || typeof newValue === "number") {
if (oldValue !== newValue) {
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
}
} else if (oldValue !== newValue) {
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
}
})
if (diff.length === 0) return "No changes detected"
return diff.join("\n")
}
function isDateTime(
value1: unknown,
value2: unknown
): { v1: string | null; v2: string | null } | null {
let v1 = null as string | null
let v2 = null as string | null
if (typeof value1 === "undefined" || value1 === null) return null
try {
if (typeof value1 == "string") {
const v1Valid = DateTime.fromISO(value1).isValid
if (v1Valid) v1 = DateTime.fromISO(value1).toUTC().toISO()
}
if (typeof value1 == "object") {
const v1Valid = DateTime.fromJSDate(value1 as Date).isValid
if (v1Valid)
v1 = DateTime.fromJSDate(value1 as Date)
.toUTC()
.toISO()
}
if (typeof value2 == "string") {
const v1Valid = DateTime.fromISO(value2).isValid
if (v1Valid) v2 = DateTime.fromISO(value2).toUTC().toISO()
}
if (typeof value2 == "object") {
const v1Valid = DateTime.fromJSDate(value2 as Date).isValid
if (v1Valid)
v2 = DateTime.fromJSDate(value2 as Date)
.toUTC()
.toISO()
}
if (!isNil(v1) || !isNil(v2)) return { v1, v2 }
return null
} catch (_error) {
return null
}
}
@@ -0,0 +1,52 @@
import {
AttributeNames,
Attributes,
FindOptions,
Model,
Op,
WhereOptions,
sql,
where,
} from "@sequelize/core"
import arrayWrap from "@/utils/array-wrap"
/**
* Generates a search scope for Sequelize models that allows for custom SQL conditions per term.
*/
export function searchFieldsByTermsFactory<M extends Model>(
fields: AttributeNames<M>[]
): (termOrTerms: string | string[]) => FindOptions<Attributes<M>> {
return (termOrTerms: string | string[]): FindOptions<Attributes<M>> => {
const terms = arrayWrap(termOrTerms)
if (terms.length === 0) {
return {}
}
// TODO: rebuild as successive scope calls once
// https://github.com/sequelize/sequelize/issues/17304 is fixed
// (we would no longer need the and operator in the where clause)
const whereQuery: {
[Op.and]?: WhereOptions<M>[]
} = {}
const whereConditions: WhereOptions<M>[] = terms.map((term: string) => {
const termPattern = `%${term.toLowerCase()}%`
const fieldsQuery = fields.map((field) => {
return where(sql.fn("LOWER", sql.attribute(field)), Op.like, termPattern)
})
return {
[Op.or]: fieldsQuery,
}
})
whereQuery[Op.and] = whereConditions
return {
where: whereQuery,
}
}
}
export default searchFieldsByTermsFactory
+5
View File
@@ -0,0 +1,5 @@
export function sleep(seconds: number) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
export default sleep
+3
View File
@@ -0,0 +1,3 @@
export function stripTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url
}
+13
View File
@@ -0,0 +1,13 @@
import { last } from "lodash"
export function toSentence(items: string[]): string {
if (items.length === 0) return ""
if (items.length === 1) return items[0]
if (items.length === 2) return items.join(" and ")
const itemsExceptLast = items.slice(0, -1).join(", ")
const lastItem = last(items)
return `${itemsExceptLast}, and ${lastItem}`
}
export default toSentence
+61
View File
@@ -0,0 +1,61 @@
import logger from "@/utils/logger"
/**
* Wraps an async function with logging for start, completion, and errors.
* Accepts positional parameters like findEach.
*/
function withLoggingFactory(
description: string,
wrappedFunction: () => Promise<void>
): () => Promise<void>
function withLoggingFactory<T extends Record<string, unknown>>(
description: string,
context: T,
wrappedFunction: (context: T) => Promise<void>
): () => Promise<void>
function withLoggingFactory<T extends Record<string, unknown>>(
description: string,
contextOrFunction?: T | (() => Promise<void>),
wrappedFunction?: (context: T) => Promise<void>
): () => Promise<void> {
// 2-argument version: (description, wrappedFunction)
if (typeof contextOrFunction === "function") {
return async () => {
logger.info(`Starting: ${description}`)
try {
await contextOrFunction()
logger.info(`Completed: ${description}`)
} catch (error) {
logger.error(`Failed: ${description}`, { error })
throw error
}
}
}
// 3-argument version: (description, context, wrappedFunction)
if (typeof contextOrFunction !== "object") {
throw new Error("Missing context")
}
const context: T = contextOrFunction
if (typeof wrappedFunction !== "function") {
throw new Error("Missing wrapped function")
}
return async () => {
logger.info(`Starting: ${description} with ${context}`, { context })
try {
await wrappedFunction(context)
logger.info(`Completed: ${description} with ${context}`, { context })
} catch (error) {
logger.error(`Failed: ${description} with ${context}`, { context, error })
throw error
}
}
}
export default withLoggingFactory