feathers-trigger
Version:
Add triggers and actions to your feathers app.
422 lines (352 loc) • 9.66 kB
text/typescript
import { getItems } from 'feathers-hooks-common'
import _get from 'lodash/get.js'
import _set from 'lodash/set.js'
import _isEqual from 'lodash/isEqual.js'
import { shouldSkip } from '@fratzinger/feathers-utils'
import type {
HookContext,
Id,
NextFunction,
Params,
} from '@feathersjs/feathers'
import type { Promisable } from '../types.internal.js'
export type Change<T = any> = {
before: T
item: T
}
export type ChangesById<T = any> = {
[key: string]: Change<T>
[key: number]: Change<T>
}
export type ManipulateParams<H extends HookContext = HookContext> = (
params: Params,
context: H,
) => Promisable<Params | null>
export interface HookChangesByIdOptions<H extends HookContext = HookContext> {
/**
* @default false
*/
skipHooks: boolean
params?: ManipulateParams<H>
/**
* @default []
*/
deleteParams?: string[]
/**
* The name of the property to store the changesById in context.params
*
* @default "changesById"
*/
name?: string | string[]
/**
* @default false
*/
fetchBefore?: boolean
}
const defaultOptions = {
skipHooks: false,
params: undefined,
name: 'changesById',
deleteParams: [],
fetchBefore: false,
} satisfies Partial<HookChangesByIdOptions>
export interface ChangesByIdParams extends Params {
changesById: any
}
declare module '@feathersjs/feathers' {
interface Params {
paginate?: any
changesById?: any
}
}
export const changesById = <H extends HookContext, T = any>(
cb: (changesById: Record<Id, Change<T>>, context: H) => void | Promise<void>,
_options?: Partial<HookChangesByIdOptions<H>>,
) => {
const options = {
...defaultOptions,
..._options,
}
return async (context: H, next?: NextFunction): Promise<H> => {
if (shouldSkip('checkMulti', context)) {
return context
}
const pathBefore = getPath(options.name, true)
if (context.type === 'before' || context.type === 'around') {
const changes = await changesByIdBefore(context, options)
if (!changes) {
return context
}
_set(context, pathBefore, changes)
}
if (next) {
await next()
}
if (context.type === 'after' || context.type === 'around') {
const itemsBefore = _get(context, pathBefore)
const changes = await changesByIdAfter(context, itemsBefore, cb, options)
if (!changes) {
return context
}
_set(context, getPath(options.name, false), changes)
}
return context
}
}
export const changesByIdBefore = async <H extends HookContext>(
context: H,
_options: HookChangesByIdOptions<H>,
): Promise<Record<string, unknown> | unknown[]> => {
const options = {
...defaultOptions,
..._options,
type: 'before' as const,
}
let byId: Record<string, unknown> | unknown[]
if (context.method === 'create' || !options.fetchBefore) {
byId = {}
} else if (
context.method === 'update' ||
context.method === 'patch' ||
context.method === 'remove'
) {
byId = (await getOrFindById(context, options)) ?? {}
} else {
return []
}
return byId
}
export const changesByIdAfter = async <H extends HookContext, T = any>(
context: H,
itemsBefore: any,
cb?:
| ((changesById: Record<Id, Change<T>>, context: H) => void | Promise<void>)
| null,
_options?: HookChangesByIdOptions<H>,
): Promise<Record<Id, Change> | undefined> => {
if (!itemsBefore) {
return
}
const options = {
...defaultOptions,
..._options,
type: 'after' as const,
}
const items = await resultById(context, options)
if (!items) {
return
}
const itemsBeforeOrAfter =
context.method === 'remove' && options.fetchBefore ? itemsBefore : items
const changesById = Object.keys(itemsBeforeOrAfter).reduce(
(result: Record<Id, Change>, id: string): Record<Id, Change> => {
if (
options.fetchBefore &&
((context.method !== 'create' && !itemsBefore[id]) ||
(context.method !== 'remove' && !items[id]))
) {
throw new Error('Mismatch!')
//return result;
}
const before = itemsBefore[id]
const item = items[id]
result[id] = {
before: before,
item: item,
}
return result
},
{},
)
if (cb && typeof cb === 'function') {
await cb(changesById as Record<Id, Change<T>>, context)
}
return changesById
}
export type GetOrFindByIdParamsOptions<H extends HookContext = HookContext> =
Pick<HookChangesByIdOptions<H>, 'params' | 'skipHooks' | 'deleteParams'> & {
type: 'before' | 'after'
}
export const getOrFindByIdParams = async <H extends HookContext = HookContext>(
context: H,
options: GetOrFindByIdParamsOptions<H>,
): Promise<Params | undefined> => {
if (context.id == null) {
if (options.type === 'before') {
let params = {
...context.params,
query: {
...context.params?.query,
},
paginate: false,
}
delete params.changesById
if (options?.deleteParams) {
options.deleteParams.forEach((key) => {
delete params[key]
})
}
if (params.query?.$select) {
delete params.query.$select
}
params =
typeof options.params === 'function'
? await options.params(params, context)
: params
return params
} else if (options.type === 'after') {
if (!options.params && !context.params.query?.$select) {
return
}
const itemOrItems = getItems(context)
const idField = getIdField(context)
if (!itemOrItems) {
return
}
const fetchedItems = Array.isArray(itemOrItems)
? itemOrItems
: [itemOrItems]
const ids = fetchedItems.map((x) => x && x[idField])
let params: Params | null = {
query: {
[idField]: { $in: ids },
},
paginate: false,
}
params = options.params ? await options.params(params, context) : params
return params ?? {}
}
} else {
if (
options.type === 'after' &&
!options.params &&
!context.params.query?.$select
) {
return
}
const query = Object.assign({}, context.params.query)
delete query.$select
let params: Params = Object.assign({}, context.params, { query })
delete params.changesById
if (options?.deleteParams) {
options.deleteParams.forEach((key) => {
delete params[key as keyof typeof params]
})
}
params =
(typeof options.params === 'function'
? await options.params(params, context)
: params) ?? {}
return params
}
}
export type GetOrFindByIdOptions<H extends HookContext = HookContext> =
GetOrFindByIdParamsOptions<H> & {
byId?: boolean
}
const getOrFindById = async <H extends HookContext, T>(
context: H,
_options: GetOrFindByIdOptions<H>,
): Promise<Record<Id, T> | T[] | undefined> => {
const options = {
byId: true,
..._options,
}
let itemOrItems
const idField = getIdField(context)
const params = await getOrFindByIdParams(context, options)
if (context.id == null) {
const method = options.skipHooks ? '_find' : 'find'
itemOrItems = await context.service[method](params)
itemOrItems = itemOrItems && (itemOrItems.data || itemOrItems)
} else {
const method = options.skipHooks ? '_get' : 'get'
itemOrItems = await context.service[method](context.id, params)
}
const items = !itemOrItems
? []
: Array.isArray(itemOrItems)
? itemOrItems
: [itemOrItems]
if (options.byId) {
return items.reduce((byId, item) => {
const id = item[idField]
byId[id] = item
return byId
}, {})
} else {
return items
}
}
const resultById = async <H extends HookContext>(
context: H,
options: GetOrFindByIdParamsOptions<H>,
): Promise<Record<string, unknown>> => {
if (!context.result) {
return {}
}
let items: Record<string, unknown>[]
let params: Params | null | undefined = await getOrFindByIdParams(
context,
options,
)
if (params) {
const contextParams = Object.assign({}, context.params)
delete contextParams.changesById
if (options?.deleteParams) {
options.deleteParams.forEach((key) => {
delete contextParams[key]
})
}
if (_isEqual(params, context.params)) {
params = null
}
}
if (context.method === 'remove' || !params) {
let itemOrItems = context.result
itemOrItems = Array.isArray(itemOrItems.data)
? itemOrItems.data
: itemOrItems
items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]
} else {
items = (await getOrFindById(context, {
skipHooks: options?.skipHooks ?? false,
byId: false,
params: () => params,
type: options.type,
})) as Record<string, unknown>[]
}
const idField = context.service.id
return items.reduce(
(
byId: Record<Id, Record<string, unknown>>,
item: Record<string, unknown>,
) => {
const id = item[idField] as Id
byId[id] = item
return byId
},
{},
)
}
const getIdField = (context: Pick<HookContext, 'service'>): string => {
return context.service.options.id
}
const getPath = (
path: string | string[],
isBefore: boolean,
): string | string[] => {
if (isBefore) {
if (typeof path === 'string') {
return `params.${path}.itemsBefore`
} else {
return ['params', ...path, 'itemsBefore']
}
} else {
if (typeof path === 'string') {
return `params.${path}`
} else {
return ['params', ...path]
}
}
}