generated from alphane/template
Initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user