Files
calendar/packages/app-store/routing-forms/trpc/utils.ts
T
2f4dd328c3 feat: new webhook 'Form submitted, no event booked' (#17119)
* add new webhook

* schedule trigger functions

* implement tasker

* fix type errors

* add tests

* remove unused code

* finish tests

* remove unsued code from debugging

* Fix: client-server mixed exports

* Update tasker.ts

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2024-10-22 03:38:33 +00:00

544 lines
17 KiB
TypeScript

import type { App_RoutingForms_Form, User } from "@prisma/client";
import async from "async";
import type { ImmutableTree, JsonTree } from "react-awesome-query-builder";
import type { Config } from "react-awesome-query-builder/lib";
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
import dayjs from "@calcom/dayjs";
import type { Tasker } from "@calcom/features/tasker/tasker";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import { RaqbLogicResult } from "../lib/evaluateRaqbLogic";
import {
getTeamMembersWithAttributeOptionValuePerAttribute,
getAttributesForTeam,
} from "../lib/getAttributes";
import isRouter from "../lib/isRouter";
import jsonLogic from "../lib/jsonLogic";
import type { SerializableField, OrderedResponses } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils";
let tasker: Tasker;
if (typeof window === "undefined") {
import("@calcom/features/tasker")
.then((module) => {
tasker = module.default;
})
.catch((error) => {
console.error("Failed to load tasker:", error);
});
}
const {
getAttributesData: getAttributes,
getAttributesQueryBuilderConfig,
getAttributesQueryValue,
} = acrossQueryValueCompatiblity;
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/utils"] });
type SelectFieldWebhookResponse = string | number | string[] | { label: string; id: string | null };
export type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record<
string,
{
/**
* Deprecates `value` prop as it now has both the id(that doesn't change) and the label(that can change but is human friendly)
*/
response: number | string | string[] | SelectFieldWebhookResponse | SelectFieldWebhookResponse[];
/**
* @deprecated Use `response` instead
*/
value: FormResponse[keyof FormResponse]["value"];
}
>;
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
ReturnType<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
>[number];
function isOptionsField(field: Pick<SerializableField, "type" | "options">) {
return (field.type === "select" || field.type === "multiselect") && field.options;
}
function getFieldResponse({
field,
fieldResponseValue,
}: {
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
field: Pick<SerializableField, "type" | "options">;
}) {
if (!isOptionsField(field)) {
return {
value: fieldResponseValue,
response: fieldResponseValue,
};
}
if (!field.options) {
return {
value: fieldResponseValue,
response: fieldResponseValue,
};
}
const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue];
const chosenOptions = valueArray.map((idOrLabel) => {
const foundOptionById = field.options?.find((option) => {
return option.id === idOrLabel;
});
if (foundOptionById) {
return {
label: foundOptionById.label,
id: foundOptionById.id,
};
} else {
return {
label: idOrLabel.toString(),
id: null,
};
}
});
return {
// value is a legacy prop that is just sending the labels which can change
value: chosenOptions.map((option) => option.label),
// response is new prop that is sending the label along with id(which doesn't change)
response: chosenOptions,
};
}
/**
* Performance wrapper for async functions
*/
async function asyncPerf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
const start = performance.now();
const result = await fn();
const end = performance.now();
return [result, end - start];
}
/**
* Performance wrapper for sync functions
*/
function perf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
const start = performance.now();
const result = fn();
const end = performance.now();
return [result, end - start];
}
function getErrorsFromImmutableTree(tree: ImmutableTree) {
const validatedQueryValue = QbUtils.getTree(tree);
if (!raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue)) {
return [];
}
if (!validatedQueryValue.children1) {
return [];
}
const errors: string[][] = [];
Object.values(validatedQueryValue.children1).map((rule) => {
if (rule.type !== "rule") {
return;
}
const valueError = rule.properties.valueError;
if (valueError) {
// Sometimes there are null values in it.
errors.push(valueError.filter((value) => !!value));
}
});
return errors;
}
function getJsonLogic({
attributesQueryValue,
attributesQueryBuilderConfig,
isPreview,
}: {
attributesQueryValue: JsonTree;
attributesQueryBuilderConfig: Config;
isPreview: boolean;
}) {
const state = {
tree: QbUtils.checkTree(
QbUtils.loadTree(attributesQueryValue),
// We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfig returns a Config. So, asserting it.
attributesQueryBuilderConfig as unknown as Config
),
config: attributesQueryBuilderConfig as unknown as Config,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
// We error only in preview mode to communicate any problem.
// In live mode, we don't error and instead prefer to let no members match which then causes all of the assignes of the team event to be used.
if (isPreview) {
const errors = getErrorsFromImmutableTree(state.tree).flat();
if (errors.length) {
throw new Error(errors.toString());
}
if (!logic) {
// Empty children1 is normal where it means that no rules are added by user.
if (attributesQueryValue.children1 && Object.keys(attributesQueryValue.children1).length > 0) {
// Possible reasons for this
// 1. The attribute option value used is not in the options list. Happens if 'Value of field' value is chosen and that field's response value doesn't exist in attribute options list.
throw new Error("There is some error building the logic, please check the routes.");
}
}
}
return logic;
}
export const enum TroubleshooterCase {
EMPTY_QUERY_VALUE = "empty-query-value",
IS_A_ROUTER = "is-a-router",
NO_LOGIC_FOUND = "no-logic-found",
MATCH_RESULTS_READY = "match-results-ready",
NO_ROUTE_FOUND = "no-route-found",
}
export async function findTeamMembersMatchingAttributeLogicOfRoute(
{
form,
response,
routeId,
teamId,
isPreview,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
response: FormResponse;
routeId: string;
teamId: number;
isPreview?: boolean;
},
config: {
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
} = {}
) {
const route = form.routes?.find((route) => route.id === routeId);
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter } = config;
if (!route) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: null,
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.NO_ROUTE_FOUND,
data: {
routeId,
},
}
: null,
};
}
if (isRouter(route)) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: null,
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.IS_A_ROUTER,
data: {
routeId,
},
}
: null,
};
}
const teamMembersMatchingAttributeLogicMap = new Map<number, RaqbLogicResult>();
const [attributesForTeam, getAttributesForTeamTimeTaken] = await aPf(
async () => await getAttributesForTeam({ teamId: teamId })
);
const [attributesQueryValue, getAttributesQueryValueTimeTaken] = pf(() =>
getAttributesQueryValue({
attributesQueryValue: route.attributesQueryValue,
attributes: attributesForTeam,
response,
fields: form.fields,
getFieldResponse,
})
);
if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: {
gAtr: getAttributesForTeamTimeTaken,
gQryVal: getAttributesQueryValueTimeTaken,
gQryCnfg: null,
gMbrWtAtr: null,
lgcFrMbrs: null,
},
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.EMPTY_QUERY_VALUE,
data: {
attributesQueryValue,
},
}
: null,
};
}
const [attributesQueryBuilderConfig, getAttributesQueryBuilderConfigTimeTaken] = pf(() =>
getAttributesQueryBuilderConfig({
form,
attributes: attributesForTeam,
attributesQueryValue,
})
);
const [
teamMembersWithAttributeOptionValuePerAttribute,
getTeamMembersWithAttributeOptionValuePerAttributeTimeTaken,
] = await aPf(() => getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }));
const logic = getJsonLogic({
attributesQueryValue: attributesQueryValue as JsonTree,
attributesQueryBuilderConfig: attributesQueryBuilderConfig as unknown as Config,
isPreview: !!isPreview,
});
if (!logic) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: {
gAtr: getAttributesForTeamTimeTaken,
gQryCnfg: getAttributesQueryBuilderConfigTimeTaken,
gMbrWtAtr: getTeamMembersWithAttributeOptionValuePerAttributeTimeTaken,
lgcFrMbrs: null,
gQryVal: getAttributesQueryValueTimeTaken,
},
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.NO_LOGIC_FOUND,
data: {
attributesQueryValue,
attributesQueryBuilderConfig,
teamMembersWithAttributeOptionValuePerAttribute,
},
}
: null,
};
}
const attributesDataPerUser = new Map<number, ReturnType<typeof getAttributes>>();
const [_, teamMembersMatchingAttributeLogicTimeTaken] = await aPf(async () => {
return await async.mapLimit<TeamMemberWithAttributeOptionValuePerAttribute, Promise<void>>(
teamMembersWithAttributeOptionValuePerAttribute,
concurrency,
async (member: TeamMemberWithAttributeOptionValuePerAttribute) => {
const attributesData = getAttributes({
attributesData: member.attributes,
attributesQueryValue,
});
if (enableTroubleshooter) {
attributesDataPerUser.set(member.userId, attributesData);
}
const result = !!jsonLogic.apply(logic as any, attributesData)
? RaqbLogicResult.MATCH
: RaqbLogicResult.NO_MATCH;
if (result !== RaqbLogicResult.MATCH) {
return;
}
teamMembersMatchingAttributeLogicMap.set(member.userId, result);
}
);
});
return {
teamMembersMatchingAttributeLogic: Array.from(teamMembersMatchingAttributeLogicMap).map((item) => ({
userId: item[0],
result: item[1],
})),
timeTaken: {
gAtr: getAttributesForTeamTimeTaken,
gQryCnfg: getAttributesQueryBuilderConfigTimeTaken,
gMbrWtAtr: getTeamMembersWithAttributeOptionValuePerAttributeTimeTaken,
lgcFrMbrs: teamMembersMatchingAttributeLogicTimeTaken,
gQryVal: getAttributesQueryValueTimeTaken,
},
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
attributesDataPerUser,
attributesQueryValue,
attributesQueryBuilderConfig,
logic,
attributesForTeam,
},
}
: null,
};
function pf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
if (!enablePerf) {
return [fn(), null];
}
return perf(fn);
}
async function aPf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}
export async function onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
"fields"
>,
response: FormResponse,
responseId: number,
chosenAction?: {
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
value: string;
}
) {
const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
const field = form.fields.find((f) => f.id === fieldId);
if (!field) {
throw new Error(`Field with id ${fieldId} not found`);
}
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByIdentifier);
fieldResponsesByIdentifier[key] = getFieldResponse({
fieldResponseValue: fieldResponse.value,
field,
});
}
const { userId, teamId } = getWebhookTargetEntity(form);
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const subscriberOptionsFormSubmitted = {
userId,
teamId,
orgId,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
};
const subscriberOptionsFormSubmittedNoEvent = {
userId,
teamId,
orgId,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
};
const webhooksFormSubmitted = await getWebhooks(subscriberOptionsFormSubmitted);
const webhooksFormSubmittedNoEvent = await getWebhooks(subscriberOptionsFormSubmittedNoEvent);
const promisesFormSubmitted = webhooksFormSubmitted.map((webhook) => {
sendGenericWebhookPayload({
secretKey: webhook.secret,
triggerEvent: "FORM_SUBMITTED",
createdAt: new Date().toISOString(),
webhook,
data: {
formId: form.id,
formName: form.name,
teamId: form.teamId,
responses: fieldResponsesByIdentifier,
},
rootData: {
// Send responses unwrapped at root level for backwards compatibility
...Object.entries(fieldResponsesByIdentifier).reduce((acc, [key, value]) => {
acc[key] = value.value;
return acc;
}, {} as Record<string, FormResponse[keyof FormResponse]["value"]>),
},
}).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e);
});
});
const promisesFormSubmittedNoEvent = webhooksFormSubmittedNoEvent.map((webhook) => {
const scheduledAt = dayjs().add(10, "minute").toDate();
return tasker.create(
"triggerFormSubmittedNoEventWebhook",
{
responseId,
form,
responses: fieldResponsesByIdentifier,
redirect: chosenAction,
webhook,
},
{ scheduledAt }
);
});
const promises = [...promisesFormSubmitted, ...promisesFormSubmittedNoEvent];
await Promise.all(promises);
const orderedResponses = form.fields.reduce((acc, field) => {
acc.push(response[field.id]);
return acc;
}, [] as OrderedResponses);
if (form.settings?.emailOwnerOnSubmission) {
moduleLogger.debug(
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
);
await sendResponseEmail(form, orderedResponses, [form.user.email]);
} else if (form.userWithEmails?.length) {
moduleLogger.debug(
`Preparing to send Form Response email for Form:${form.id} to users: ${form.userWithEmails.join(",")}`
);
await sendResponseEmail(form, orderedResponses, form.userWithEmails);
}
}
export const sendResponseEmail = async (
form: Pick<App_RoutingForms_Form, "id" | "name">,
orderedResponses: OrderedResponses,
toAddresses: string[]
) => {
try {
if (typeof window === "undefined") {
const { default: ResponseEmail } = await import("../emails/templates/response-email");
const email = new ResponseEmail({ form: form, toAddresses, orderedResponses });
await email.sendEmail();
}
} catch (e) {
moduleLogger.error("Error sending response email", e);
}
};
function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) {
// If it's a team form, the target must be team webhook
// If it's a user form, the target must be user webhook
const isTeamForm = form.teamId;
return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null };
}