fratzinger/feathers-trigger

View on GitHub
src/hooks/trigger.ts

Summary

Maintainability
D
1 day
Test Coverage
A
95%
import { checkContext } from "feathers-hooks-common";
import type { ManipulateParams, Change } from "./changesById";
import {
  changesByIdBefore,
  changesByIdAfter,
  getOrFindByIdParams,
} from "./changesById";
import { replace as transformMustache } from "object-replace-mustache";
import sift from "sift";
import copy from "fast-copy";
import _set from "lodash/set.js";

import type {
  HookContext,
  Id,
  Paginated,
  Params,
  ServiceInterface,
} from "@feathersjs/feathers";
import type { Promisable } from "../types.internal";

interface ViewContext<H extends HookContext = HookContext, T = any> {
  item: Change<T>;
  subscription: Subscription<H, T>;
  subscriptions: Subscription<H, T>[];
  items: Change<T>[];
  context: HookContext;
}

export type ActionOptions<H extends HookContext = HookContext, T = any> = {
  subscription: SubscriptionResolved<H, T>;
  items: Change<T>[];
  context: H;
  view: Record<string, any>;
};

export type Action<H extends HookContext = HookContext, T = any> = (
  change: Change<T>,
  options: ActionOptions<H, T>,
) => Promisable<void>;

export type BatchAction<H extends HookContext = HookContext, T = any> = (
  changes: [change: Change<T>, options: ActionOptions<H, T>][],
  context: H,
) => Promisable<void>;

export type HookTriggerOptions<H extends HookContext = HookContext, T = any> =
  | Subscription<H, T>
  | (Subscription<H, T>[] & { batchMode?: never })
  | (((context: H) => Promisable<Subscription<H, T> | Subscription<H, T>[]>) & {
      batchMode?: never;
    });

export type TransformView<H extends HookContext = HookContext, T = any> =
  | undefined
  | ((
      view: Record<string, any>,
      viewContext: ViewContext<H, T>,
    ) => Promisable<Record<string, any>>)
  | Record<string, any>;

export type Condition<H extends HookContext, T = Record<string, any>> =
  | true
  | Record<string, any>
  | ((item: T, context: H) => Promisable<boolean>);

export interface SubscriptionBase<
  H extends HookContext = HookContext,
  T = Record<string, any>,
> {
  /**
   * The name of the subscription
   *
   * Can be used to filter subscriptions
   */
  name?: string;
  service?: string | string[];
  method?: string | string[];
  conditionsData?: Condition<H, T>;
  conditionsResult?: Condition<H, T>;
  conditionsBefore?: Condition<H, T>;
  conditionsParams?: Condition<H, T>;
  view?: TransformView<H, T>;
  params?: ManipulateParams;
  /** @default true */
  isBlocking?: boolean;
  /** @default false */
  fetchBefore?: boolean;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [key: string]: any;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [key: number]: any;
}

export type SubscriptionStandardMode<H extends HookContext, T> = {
  batchMode?: false;
  action: Action<H, T>;
} & SubscriptionBase<H, T>;

export type SubscriptionBatchMode<H extends HookContext, T> = {
  batchMode: true;
  action: BatchAction<H, T>;
} & SubscriptionBase<H, T>;

export type Subscription<
  H extends HookContext = HookContext,
  T = Record<string, any>,
> = SubscriptionStandardMode<H, T> | SubscriptionBatchMode<H, T>;

export type SubscriptionResolvedBase<
  H extends HookContext = HookContext,
  T = Record<string, any>,
> = {
  dataResolved: boolean | Record<string, any>;
  resultResolved: boolean | Record<string, any>;
  beforeResolved: boolean | Record<string, any>;
  paramsResolved: Params;
  identifier: string;
};

export type SubscriptionResolvedStandardMode<
  H extends HookContext = HookContext,
  T = Record<string, any>,
> = SubscriptionResolvedBase<H, T> & SubscriptionStandardMode<H, T>;

export type SubscriptionResolvedBatchMode<
  H extends HookContext = HookContext,
  T = Record<string, any>,
> = SubscriptionResolvedBase<H, T> & SubscriptionBatchMode<H, T>;

export type SubscriptionResolved<
  H extends HookContext = HookContext,
  T = Record<string, any>,
> =
  | SubscriptionResolvedStandardMode<H, T>
  | SubscriptionResolvedBatchMode<H, T>;

export const trigger = <
  H extends HookContext,
  T = H extends HookContext<infer app, infer S>
    ? S extends ServiceInterface<infer TT>
      ? TT extends Paginated<infer TTT>
        ? TTT
        : TT extends Array<infer TTT>
          ? TTT
          : TT
      : any
    : any,
  Options extends HookTriggerOptions<H, T> = HookTriggerOptions<H, T>,
>(
  options: Options,
) => {
  if (!options) {
    throw new Error("You should define subscriptions");
  }

  return async (context: H): Promise<H> => {
    checkContext(
      context,
      null,
      ["create", "update", "patch", "remove"],
      "trigger",
    );

    if (context.type === "before") {
      return await triggerBefore(context, options);
    } else if (context.type === "after") {
      return await triggerAfter(context);
    } else {
      return context;
    }
  };
};

const triggerBefore = async <H extends HookContext, T = Record<string, any>>(
  context: H,
  options: HookTriggerOptions<H, T>,
): Promise<H> => {
  let subs = await getSubscriptions(context, options);

  if (!subs?.length) {
    return context;
  }

  if (!Array.isArray(context.data)) {
    const result: SubscriptionResolved<H, T>[] = [];
    await Promise.all(
      subs.map(async (sub) => {
        if (!sub.action) {
          return;
        }

        if (
          sub.name &&
          context.params.skipTrigger &&
          (context.params.skipTrigger === sub.name ||
            (Array.isArray(context.params.skipTrigger) &&
              context.params.skipTrigger.includes(sub.name)))
        ) {
          return;
        }

        sub.dataResolved =
          typeof sub.conditionsData === "function"
            ? await sub.conditionsData(context.data, context)
            : testCondition(context, context.data, sub.conditionsData ?? {});
        if (sub.dataResolved === false) {
          return;
        }

        sub.conditionsParamsResolved =
          typeof sub.conditionsParams === "function"
            ? await sub.conditionsParams(context.data, context)
            : testCondition(
                context,
                context.params,
                sub.conditionsParams ?? {},
              );
        if (sub.conditionsParamsResolved === false) {
          return;
        }

        result.push(sub);
      }),
    );
    subs = result;
  }

  if (!subs?.length) {
    return context;
  }

  for (const sub of subs) {
    if (checkConditions(sub)) {
      continue;
    }

    sub.paramsResolved =
      (await getOrFindByIdParams(context, sub.params, {
        deleteParams: ["trigger"],
      })) ?? {};

    sub.identifier = JSON.stringify(sub.paramsResolved);
    if (context.params.changesById?.[sub.identifier]?.itemsBefore) {
      continue;
    }

    const before = await changesByIdBefore(context, {
      skipHooks: false,
      params: () => (sub.paramsResolved ? sub.paramsResolved : null),
      deleteParams: ["trigger"],
      fetchBefore: sub.fetchBefore || sub.conditionsBefore !== true,
    });

    _set(
      context,
      ["params", "changesById", sub.identifier, "itemsBefore"],
      before,
    );
  }

  setConfig(context, "subscriptions", subs);

  return context;
};

const triggerAfter = async <H extends HookContext>(context: H): Promise<H> => {
  const subs = getConfig(context, "subscriptions");
  if (!subs?.length) {
    return context;
  }

  const now = new Date();

  const promises: Promisable<any>[] = [];

  for (const sub of subs) {
    if (
      sub.name &&
      context.params.skipTrigger &&
      (context.params.skipTrigger === sub.name ||
        (Array.isArray(context.params.skipTrigger) &&
          context.params.skipTrigger.includes(sub.name)))
    ) {
      return context;
    }

    if (checkConditions(sub)) {
      continue;
    }
    const itemsBefore =
      context.params.changesById?.[sub.identifier]?.itemsBefore;
    let changesById: Record<Id, Change> | undefined;
    if (itemsBefore) {
      changesById = await changesByIdAfter(context, itemsBefore, null, {
        name: ["changesById", sub.identifier],
        params: sub.params,
        skipHooks: false,
        deleteParams: ["trigger"],
        fetchBefore: sub.fetchBefore,
      });

      _set(context, ["params", "changesById", sub.identifier], changesById);
    }

    changesById = context.params.changesById?.[sub.identifier];

    if (!changesById) {
      continue;
    }

    const changes = Object.values(changesById);

    const batchActionArguments: [change: Change, options: ActionOptions][] = [];

    for (const change of changes) {
      const { before } = change;
      const { item } = change;

      const changeForSub = change;

      const { conditionsResult, conditionsBefore } = sub;

      let mustacheView: Record<string, unknown> = {
        item,
        before,
        data: context.data,
        id: context.id,
        method: context.method,
        now,
        params: context.params,
        path: context.path,
        service: context.service,
        type: context.type,
        user: context.params?.user,
      };

      if (sub.view) {
        if (typeof sub.view === "function") {
          mustacheView = await sub.view(mustacheView, {
            item: changeForSub,
            items: changes,
            subscription: sub,
            subscriptions: subs,
            context,
          });
        } else {
          mustacheView = Object.assign(mustacheView, sub.view);
        }
      }

      sub.resultResolved =
        typeof conditionsResult === "function"
          ? await conditionsResult({ item, before }, context)
          : testCondition(mustacheView, item, conditionsResult ?? {});
      if (!sub.resultResolved) {
        continue;
      }

      sub.beforeResolved =
        typeof conditionsBefore === "function"
          ? await conditionsBefore({ item, before }, context)
          : testCondition(mustacheView, before, conditionsBefore ?? {});
      if (!sub.beforeResolved) {
        continue;
      }

      if (isSubscriptionInBatchMode(sub)) {
        batchActionArguments.push([
          changeForSub,
          {
            subscription: sub,
            items: changes,
            context,
            view: mustacheView,
          },
        ]);
      } else if (isSubscriptionNormalMode(sub)) {
        const _action = sub.action;

        const promise = _action(changeForSub, {
          subscription: sub,
          items: changes,
          context,
          view: mustacheView,
        });

        if (sub.isBlocking) {
          promises.push(promise);
        }
      }
    }

    if (isSubscriptionInBatchMode(sub) && batchActionArguments.length) {
      const promise = sub.action(batchActionArguments, context);

      if (sub.isBlocking) {
        promises.push(promise);
      }
    }
  }

  await Promise.all(promises);

  return context;
};

function setConfig(
  context: HookContext,
  key: "subscriptions",
  val: SubscriptionResolved<any, any>[],
): void;
function setConfig(context: HookContext, key: string, val: unknown): void {
  context.params.trigger = context.params.trigger || {};
  context.params.trigger[key] = val;
}

function getConfig(
  context: HookContext,
  key: "subscriptions",
): undefined | SubscriptionResolved[];
function getConfig(
  context: HookContext,
  key: string,
): undefined | SubscriptionResolved[] {
  return context.params.trigger?.[key];
}

function checkConditions(
  sub: SubscriptionResolved<any, any> | Subscription<any, any>,
): boolean {
  return !sub.conditionsBefore && !sub.conditionsData && !sub.conditionsResult;
}

const defaultSubscription: Partial<SubscriptionResolved> = {
  name: undefined,
  action: undefined,
  conditionsBefore: true,
  conditionsData: true,
  conditionsParams: true,
  conditionsResult: true,
  dataResolved: undefined,
  beforeResolved: undefined,
  resultResolved: undefined,
  isBlocking: true,
  batchMode: false,
  method: undefined,
  params: undefined,
  service: undefined,
  view: undefined,
  paramsResolved: undefined,
  identifier: "default",
  fetchBefore: false,
};

const getSubscriptions = async <H extends HookContext, T = any>(
  context: H,
  options: HookTriggerOptions<H, T>,
): Promise<undefined | SubscriptionResolved<H, T>[]> => {
  const _subscriptions =
    typeof options === "function" ? await options(context) : options;

  if (!_subscriptions) {
    return;
  }

  const subscriptions = Array.isArray(_subscriptions)
    ? _subscriptions
    : [_subscriptions];

  const subscriptionsResolved = subscriptions.map(
    (x) =>
      Object.assign({}, defaultSubscription, x) as SubscriptionResolved<H, T>,
  );

  const { path, method } = context;

  return subscriptionsResolved.filter((sub) => {
    if (
      (typeof sub.service === "string" && sub.service !== path) ||
      (Array.isArray(sub.service) && !sub.service.includes(path))
    ) {
      return false;
    }
    if (
      (typeof sub.method === "string" && sub.method !== method) ||
      (Array.isArray(sub.method) && !sub.method.includes(method))
    ) {
      return false;
    }
    return true;
  });
};

const isSubscriptionInBatchMode = (
  sub: SubscriptionResolved,
): sub is SubscriptionResolvedBatchMode => !!sub.batchMode;

const isSubscriptionNormalMode = (
  sub: SubscriptionResolved,
): sub is SubscriptionResolvedStandardMode => !sub.batchMode;

const testCondition = (
  mustacheView: Record<any, any>,
  item: unknown,
  conditions: true | Record<string, any>,
): boolean | Record<string, unknown> => {
  if (conditions === true) {
    return true;
  }

  conditions = copy(conditions);
  const transformedConditions = transformMustache(conditions, mustacheView);
  return sift(transformedConditions)(item) ? transformedConditions : false;
};