feat: Routing form submitted but no booking - Salesforce actions (#18616)

* Add booking incomplete actions table

* Add incomplete booking page tab

* Add `getincompleteBookingSettings` trpc endpoints

* Add incomplete booking page

* Abstract enabled apps array

* Handle no enabled credentials and no actions

* Add enabled field to incomplete booking action db record

* Add new write record entries

* UI add separation between switch and inputs

* Fix typo

* clean up

* Add saveIncompleteBookingSettings endpoint

* Save incomplete booking settings

* Fix language around when to write to field

* Add `credentialId` to action record

* Choose which credential to assign to action

* Save credential to action

* Revert "Save credential to action"

This reverts commit ba6a1c808baed54f6d3d4c6155cf192c813d0696.

* Revert "Choose which credential to assign to action"

This reverts commit 968f6e5295d098c64abe95268afd1fa139a175ba.

* Revert "Add `credentialId` to action record"

This reverts commit 579f9ff4167b65aa987659e4e8f8c26f9e773317.

* Add credentialId to action record - rewrite migration file

* Revert "Add credentialId to action record - rewrite migration file"

This reverts commit 2843a92c61820e7f7a1614a557d3f7ea96342107.

* Revert "Add booking incomplete actions table"

This reverts commit 7ec75bef4a0f0a9d07be0142da64c49d739439ea.

* Revert "Add enabled field to incomplete booking action db record"

This reverts commit d279a1da05819eafa8fc5d664e3be733e0687e8d.

* Write migration in single commit

* Rename table

* Rename table - remove underscores

* Remove credential relationship

* Type fix - changing table name

* Fix table name

* Change writeToRecordObject to object

* Salesforce add incomplete booking, write to record

* Add incomplete booking actions to `triggerFormSubmittedNoEventWebhooks`

* Remove console.log

* Type fixes

* Type fixes

* Iterate if incompleteBookingActions

* Choose which credential to assign to action

* Save credential to action

* Fix getServerSideProp changes

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Joe Au-Yeung
2025-01-14 23:17:16 +00:00
committed by GitHub
co-authored by Alex van Andel
parent 7a8b9ededb
commit 66b3e735d8
19 changed files with 756 additions and 21 deletions
@@ -2926,5 +2926,6 @@
"managed_users": "Managed Users",
"managed_users_description": "See all the managed users created by your OAuth client",
"select_oAuth_client": "Select Oauth Client",
"on_every_instance": "On every instance",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -38,6 +38,10 @@ export default function RoutingNavBar({
target: "_blank",
href: `${appUrl}/reporting/${form?.id}`,
},
{
name: "Incomplete Booking",
href: `${appUrl}/incomplete-booking/${form?.id}`,
},
];
return (
<div className="mb-4">
@@ -0,0 +1 @@
export const enabledIncompleteBookingApps = ["salesforce"];
@@ -0,0 +1,10 @@
import type { z } from "zod";
import { routingFormIncompleteBookingDataSchema as salesforceRoutingFormIncompleteBookingDataSchema } from "@calcom/app-store/salesforce/zod";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";
const incompleteBookingActionDataSchemas: Record<IncompleteBookingActionType, z.ZodType<any>> = {
[IncompleteBookingActionType.SALESFORCE]: salesforceRoutingFormIncompleteBookingDataSchema,
};
export default incompleteBookingActionDataSchemas;
@@ -0,0 +1,13 @@
import type { App_RoutingForms_IncompleteBookingActions } from "@prisma/client";
import { incompleteBookingAction as salesforceIncompleteBookingAction } from "@calcom/app-store/salesforce/lib/routingForm/incompleteBookingAction";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";
const incompleteBookingActionFunctions: Record<
IncompleteBookingActionType,
(action: App_RoutingForms_IncompleteBookingActions, email: string) => void
> = {
[IncompleteBookingActionType.SALESFORCE]: salesforceIncompleteBookingAction,
};
export default incompleteBookingActionFunctions;
@@ -10,4 +10,5 @@ export const routingServerSidePropsConfig: Record<string, AppGetServerSideProps>
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
reporting: getServerSidePropsSingleForm,
"incomplete-booking": getServerSidePropsSingleForm,
};
@@ -1,6 +1,7 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
import * as IncompleteBooking from "./incomplete-booking/[...appPages]";
import * as LayoutHandler from "./layout-handler/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
@@ -13,6 +14,7 @@ const routingConfig = {
"routing-link": RoutingLink,
reporting: Reporting,
layoutHandler: LayoutHandler,
"incomplete-booking": IncompleteBooking,
};
export default routingConfig;
@@ -0,0 +1,329 @@
import { useState, useEffect } from "react";
import type z from "zod";
import { WhenToWriteToRecord, SalesforceFieldType } from "@calcom/app-store/salesforce/lib/enums";
import type { writeToRecordDataSchema as salesforceWriteToRecordDataSchema } from "@calcom/app-store/salesforce/zod";
import { routingFormIncompleteBookingDataSchema as salesforceRoutingFormIncompleteBookingDataSchema } from "@calcom/app-store/salesforce/zod";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Switch, InputField, Button, Select, showToast } from "@calcom/ui";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import { enabledIncompleteBookingApps } from "../../lib/enabledIncompleteBookingApps";
function Page({ form }: { form: RoutingFormWithResponseCount }) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.appRoutingForms.getIncompleteBookingSettings.useQuery({
formId: form.id,
});
const mutation = trpc.viewer.appRoutingForms.saveIncompleteBookingSettings.useMutation({
onSuccess: () => {
showToast(t("success"), "success");
},
onError: (error) => {
showToast(t(`error: ${error.message}`), "error");
},
});
const [salesforceWriteToRecordObject, setSalesforceWriteToRecordObject] = useState<
z.infer<typeof salesforceWriteToRecordDataSchema>
>({});
// Handle just Salesforce for now but need to expand this to other apps
const [salesforceActionEnabled, setSalesforceActionEnabled] = useState<boolean>(false);
const fieldTypeOptions = [{ label: t("text"), value: SalesforceFieldType.TEXT }];
const [selectedFieldType, setSelectedFieldType] = useState(fieldTypeOptions[0]);
const whenToWriteToRecordOptions = [
{ label: t("on_every_instance"), value: WhenToWriteToRecord.EVERY_BOOKING },
{ label: t("only_if_field_is_empty"), value: WhenToWriteToRecord.FIELD_EMPTY },
];
const [selectedWhenToWrite, setSelectedWhenToWrite] = useState(whenToWriteToRecordOptions[0]);
const [newSalesforceAction, setNewSalesforceAction] = useState({
field: "",
fieldType: selectedFieldType.value,
value: "",
whenToWrite: WhenToWriteToRecord.FIELD_EMPTY,
});
const credentialOptions = data?.credentials.map((credential) => ({
label: credential.team?.name,
value: credential.id,
}));
const [selectedCredential, setSelectedCredential] = useState(
Array.isArray(credentialOptions) ? credentialOptions[0] : null
);
useEffect(() => {
const salesforceAction = data?.incompleteBookingActions.find(
(action) => action.actionType === IncompleteBookingActionType.SALESFORCE
);
if (salesforceAction) {
setSalesforceActionEnabled(salesforceAction.enabled);
const parsedSalesforceActionData = salesforceRoutingFormIncompleteBookingDataSchema.safeParse(
salesforceAction.data
);
if (parsedSalesforceActionData.success) {
setSalesforceWriteToRecordObject(parsedSalesforceActionData.data?.writeToRecordObject ?? {});
}
setSelectedCredential(
credentialOptions
? credentialOptions.find((option) => option.value === salesforceAction?.credentialId) ??
selectedCredential
: selectedCredential
);
}
}, [data]);
if (isLoading) {
return <div>Loading...</div>;
}
// Check to see if the user has any compatible credentials
if (
!data?.credentials.some((credential) => enabledIncompleteBookingApps.includes(credential?.appId ?? ""))
) {
return <div>No apps installed that support this feature</div>;
}
return (
<>
<div className="bg-default border-subtle rounded-md border p-8">
<div>
<Switch
labelOnLeading
label="Write to Salesforce contact/lead record"
checked={salesforceActionEnabled}
onCheckedChange={(checked) => {
setSalesforceActionEnabled(checked);
}}
/>
</div>
{salesforceActionEnabled ? (
<>
<hr className="mt-4 border" />
{form.team && (
<>
<div className="mt-2">
<p>Credential to use</p>
<Select
options={credentialOptions}
value={selectedCredential}
onChange={(option) => {
if (!option) {
return;
}
setSelectedCredential(option);
}}
/>
</div>
<hr className="mt-4 border" />
</>
)}
<div className="mt-2">
<div className="grid grid-cols-5 gap-4">
<div>{t("field_name")}</div>
<div>{t("field_type")}</div>
<div>{t("value")}</div>
<div>{t("when_to_write")}</div>
</div>
<div>
{Object.keys(salesforceWriteToRecordObject).map((key) => {
const action =
salesforceWriteToRecordObject[key as keyof typeof salesforceWriteToRecordObject];
return (
<div className="mt-2 grid grid-cols-5 gap-4" key={key}>
<div>
<InputField value={key} readOnly />
</div>
<div>
<Select
value={fieldTypeOptions.find((option) => option.value === action.fieldType)}
isDisabled={true}
/>
</div>
<div>
<InputField value={action.value} readOnly />
</div>
<div>
<Select
value={whenToWriteToRecordOptions.find(
(option) => option.value === action.whenToWrite
)}
isDisabled={true}
/>
</div>
<div>
<Button
StartIcon="trash"
variant="icon"
color="destructive"
onClick={() => {
const newActions = { ...salesforceWriteToRecordObject };
delete newActions[key];
setSalesforceWriteToRecordObject(newActions);
}}
/>
</div>
</div>
);
})}
<div className="mt-2 grid grid-cols-5 gap-4">
<div>
<InputField
value={newSalesforceAction.field}
onChange={(e) =>
setNewSalesforceAction({
...newSalesforceAction,
field: e.target.value,
})
}
/>
</div>
<div>
<Select
options={fieldTypeOptions}
value={selectedFieldType}
onChange={(e) => {
if (e) {
setSelectedFieldType(e);
setNewSalesforceAction({
...newSalesforceAction,
fieldType: e.value,
});
}
}}
/>
</div>
<div>
<InputField
value={newSalesforceAction.value}
onChange={(e) =>
setNewSalesforceAction({
...newSalesforceAction,
value: e.target.value,
})
}
/>
</div>
<div>
<Select
options={whenToWriteToRecordOptions}
value={selectedWhenToWrite}
onChange={(e) => {
if (e) {
setSelectedWhenToWrite(e);
setNewSalesforceAction({
...newSalesforceAction,
whenToWrite: e.value,
});
}
}}
/>
</div>
</div>
</div>
<Button
className="mt-2"
size="sm"
disabled={
!(
newSalesforceAction.field &&
newSalesforceAction.fieldType &&
newSalesforceAction.value &&
newSalesforceAction.whenToWrite
)
}
onClick={() => {
if (Object.keys(salesforceWriteToRecordObject).includes(newSalesforceAction.field.trim())) {
showToast("Field already exists", "error");
return;
}
setSalesforceWriteToRecordObject({
...salesforceWriteToRecordObject,
[newSalesforceAction.field]: {
fieldType: newSalesforceAction.fieldType,
value: newSalesforceAction.value,
whenToWrite: newSalesforceAction.whenToWrite,
},
});
setNewSalesforceAction({
field: "",
fieldType: selectedFieldType.value,
value: "",
whenToWrite: WhenToWriteToRecord.FIELD_EMPTY,
});
}}>
{t("add_new_field")}
</Button>
</div>
</>
) : null}
</div>
<div className="mt-2 flex justify-end">
<Button
size="sm"
disabled={mutation.isPending}
onClick={() => {
mutation.mutate({
formId: form.id,
data: {
writeToRecordObject: salesforceWriteToRecordObject,
},
actionType: IncompleteBookingActionType.SALESFORCE,
enabled: salesforceActionEnabled,
credentialId: selectedCredential?.value ?? data.credentials[0].id,
});
}}>
{t("save")}
</Button>
</div>
</>
);
}
export default function IncompleteBookingPage({
form,
appUrl,
enrichedWithUserProfileForm,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
Page={Page}
/>
);
}
IncompleteBookingPage.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};
export { getServerSideProps };
@@ -7,8 +7,10 @@ import { ZDeleteFormInputSchema } from "./deleteForm.schema";
import { ZFormMutationInputSchema } from "./formMutation.schema";
import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
// eslint-disable-next-line @typescript-eslint/ban-types
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
@@ -88,6 +90,26 @@ const appRoutingForms = router({
);
return handler({ ctx, input });
}),
getIncompleteBookingSettings: authedProcedure
.input(ZGetIncompleteBookingSettingsInputSchema)
.query(async ({ ctx, input }) => {
const handler = await getHandler(
"getIncompleteBookingSettings",
() => import("./getIncompleteBookingSettings.handler")
);
return handler({ ctx, input });
}),
saveIncompleteBookingSettings: authedProcedure
.input(ZSaveIncompleteBookingSettingsInputSchema)
.mutation(async ({ ctx, input }) => {
const handler = await getHandler(
"saveIncompleteBookingSettings",
() => import("./saveIncompleteBookingSettings.handler")
);
return handler({ ctx, input });
}),
});
export default appRoutingForms;
@@ -0,0 +1,96 @@
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { enabledIncompleteBookingApps } from "../lib/enabledIncompleteBookingApps";
import type { TGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
interface GetIncompleteBookingSettingsOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TGetIncompleteBookingSettingsInputSchema;
}
const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBookingSettingsOptions) => {
const {
ctx: { prisma },
input,
} = options;
const [incompleteBookingActions, form] = await Promise.all([
prisma.app_RoutingForms_IncompleteBookingActions.findMany({
where: {
formId: input.formId,
},
}),
prisma.app_RoutingForms_Form.findFirst({
where: {
id: input.formId,
},
select: {
userId: true,
teamId: true,
},
}),
]);
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
const teamId = form?.teamId;
const userId = form.userId;
if (teamId) {
// Need to get the credentials for the team and org
const orgQuery = await prisma.team.findFirst({
where: {
id: teamId,
},
select: {
parentId: true,
},
});
const credentials = await prisma.credential.findMany({
where: {
appId: {
in: enabledIncompleteBookingApps,
},
teamId: {
in: [teamId, ...(orgQuery?.parentId ? [orgQuery.parentId] : [])],
},
},
include: {
team: {
select: {
name: true,
},
},
},
});
return { incompleteBookingActions, credentials };
}
if (userId) {
// Assume that a user will have one credential per app
const credential = await prisma.credential.findFirst({
where: {
appId: {
in: enabledIncompleteBookingApps,
},
userId,
},
});
return { incompleteBookingActions, credentials: credential ? [{ ...credential, team: null }] : [] };
}
};
export default getInCompleteBookingSettingsHandler;
@@ -0,0 +1,9 @@
import z from "zod";
export const ZGetIncompleteBookingSettingsInputSchema = z.object({
formId: z.string(),
});
export type TGetIncompleteBookingSettingsInputSchema = z.infer<
typeof ZGetIncompleteBookingSettingsInputSchema
>;
@@ -0,0 +1,78 @@
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import incompleteBookingActionDataSchemas from "../lib/incompleteBooking/actionDataSchemas";
import type { TSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
const log = logger.getSubLogger({ prefix: ["incomplete-booking"] });
interface SaveIncompleteBookingSettingsOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TSaveIncompleteBookingSettingsInputSchema;
}
const saveIncompleteBookingSettingsHandler = async (options: SaveIncompleteBookingSettingsOptions) => {
const {
ctx: { prisma },
input,
} = options;
const { formId, actionType, data } = input;
const dataSchema = incompleteBookingActionDataSchemas[actionType];
if (!dataSchema) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Action data schema not found",
});
}
const parsedData = dataSchema.safeParse(data);
if (!parsedData.success) {
log.error("Data is not valid", data, parsedData.error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Data is not valid",
});
}
// Check to see if the action already exists
const existingAction = await prisma.app_RoutingForms_IncompleteBookingActions.findFirst({
where: {
formId: formId,
actionType: actionType,
},
});
if (existingAction) {
await prisma.app_RoutingForms_IncompleteBookingActions.update({
where: {
id: existingAction.id,
},
data: {
data: parsedData.data,
enabled: input.enabled,
credentialId: input?.credentialId,
},
});
} else {
await prisma.app_RoutingForms_IncompleteBookingActions.create({
data: {
formId: formId,
actionType: actionType,
data: parsedData.data,
enabled: input.enabled,
credentialId: input?.credentialId,
},
});
}
};
export default saveIncompleteBookingSettingsHandler;
@@ -0,0 +1,15 @@
import z from "zod";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";
export const ZSaveIncompleteBookingSettingsInputSchema = z.object({
formId: z.string(),
actionType: z.nativeEnum(IncompleteBookingActionType),
data: z.record(z.any()).optional(),
enabled: z.boolean(),
credentialId: z.number().optional(),
});
export type TSaveIncompleteBookingSettingsInputSchema = z.infer<
typeof ZSaveIncompleteBookingSettingsInputSchema
>;
@@ -17,6 +17,7 @@ import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import { default as appMeta } from "../config.json";
import type { writeToRecordDataSchema } from "../zod";
import {
SalesforceRecordEnum,
SalesforceFieldType,
@@ -962,7 +963,7 @@ export default class SalesforceCRMService implements CRM {
existingFields: Field[];
personRecord: Record<string, any>;
onBookingWriteToRecordFields: Record<string, any>;
startTime: string;
startTime?: string;
bookingUid?: string | null;
organizerEmail?: string;
calEventResponses?: CalEventResponses | null;
@@ -989,7 +990,7 @@ export default class SalesforceCRMService implements CRM {
if (extractedText) {
writeOnRecordBody[field.name] = extractedText;
}
} else if (field.type === SalesforceFieldType.DATE) {
} else if (field.type === SalesforceFieldType.DATE && startTime && organizerEmail) {
const dateValue = await this.getDateFieldValue(
fieldConfig.value,
startTime,
@@ -1239,4 +1240,60 @@ export default class SalesforceCRMService implements CRM {
const response = await checkIfFreeEmailDomain(attendeeEmail);
return response;
}
async incompleteBookingWriteToRecord(
email: string,
writeToRecordObject: z.infer<typeof writeToRecordDataSchema>
) {
const conn = await this.conn;
let personRecord: { Id: string; Email: string; recordType: SalesforceRecordEnum } | null = null;
// Prioritize contacts over leads
const contactsQuery = await conn.query(
`SELECT Id, Email FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${email}'`
);
if (contactsQuery.records.length) {
personRecord = {
...(contactsQuery.records[0] as { Id: string; Email: string }),
recordType: SalesforceRecordEnum.CONTACT,
};
}
const leadsQuery = await conn.query(
`SELECT Id, Email FROM ${SalesforceRecordEnum.LEAD} WHERE Email = '${email}'`
);
if (leadsQuery.records.length) {
personRecord = {
...(leadsQuery.records[0] as { Id: string; Email: string }),
recordType: SalesforceRecordEnum.LEAD,
};
}
if (!personRecord) {
throw new Error(`No contact or lead found for email ${email}`);
}
// Ensure the fields exist on the record
const existingFields = await this.ensureFieldsExistOnObject(
Object.keys(writeToRecordObject),
personRecord.recordType
);
const writeOnRecordBody = await this.buildRecordUpdatePayload({
existingFields,
personRecord,
onBookingWriteToRecordFields: writeToRecordObject,
});
await conn
.sobject(personRecord.recordType)
.update({
Id: personRecord.Id,
...writeOnRecordBody,
})
.catch((e) => {
this.log.error(`Error updating person record for contactId ${personRecord?.Id}`, e);
});
}
}
@@ -0,0 +1,33 @@
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "../../../../lib/server/repository/credential";
import { routingFormIncompleteBookingDataSchema } from "../../zod";
import SalesforceCRMService from "../CrmService";
const log = logger.getSubLogger({ prefix: ["incomplete-booking: salesforce"] });
export const incompleteBookingAction = async (action: any, email: string) => {
const dataParse = routingFormIncompleteBookingDataSchema.safeParse(action.data);
if (!dataParse.success) {
log.error(`Incomplete booking action data is not valid: ${dataParse.error}`);
return;
}
const { writeToRecordObject } = dataParse.data;
const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({ id: action.credentialId });
if (!credential) {
log.error(`Credential with id ${action.credentialId} not found`);
return;
}
if (writeToRecordObject) {
const crm = new SalesforceCRMService(credential, {});
await crm.incompleteBookingWriteToRecord(email, writeToRecordObject);
}
return;
};
+15 -2
View File
@@ -3,12 +3,21 @@ import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
import { SalesforceRecordEnum, WhenToWriteToRecord, SalesforceFieldType } from "./lib/enums";
const writeToRecordEntry = z.object({
const writeToBookingEntry = z.object({
value: z.string(),
fieldType: z.nativeEnum(SalesforceFieldType),
whenToWrite: z.nativeEnum(WhenToWriteToRecord),
});
export const writeToRecordEntrySchema = z.object({
field: z.string(),
fieldType: z.nativeEnum(SalesforceFieldType),
value: z.string(),
whenToWrite: z.nativeEnum(WhenToWriteToRecord),
});
export const writeToRecordDataSchema = z.record(z.string(), writeToBookingEntry);
export const routingFormOptions = z
.object({
rrSkipToAccountLookupField: z.boolean().optional(),
@@ -16,6 +25,10 @@ export const routingFormOptions = z
})
.optional();
export const routingFormIncompleteBookingDataSchema = z.object({
writeToRecordObject: writeToRecordDataSchema.optional(),
});
const optionalBooleanOnlyRunTimeValidation = z
.any()
.refine((val) => typeof val === "boolean" || val === undefined)
@@ -40,7 +53,7 @@ export const appDataSchema = eventTypeAppCardZod.extend({
sendNoShowAttendeeData: z.boolean().optional(),
sendNoShowAttendeeDataField: z.string().optional(),
onBookingWriteToRecord: z.boolean().optional(),
onBookingWriteToRecordFields: z.record(z.string(), writeToRecordEntry).optional(),
onBookingWriteToRecordFields: z.record(z.string(), writeToBookingEntry).optional(),
ignoreGuests: z.boolean().optional(),
});
@@ -1,5 +1,6 @@
import { z } from "zod";
import incompleteBookingActionFunctions from "@calcom/app-store/routing-forms/lib/incompleteBooking/actionFunctions";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/trpc/utils";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import prisma from "@calcom/prisma";
@@ -43,7 +44,6 @@ export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({
export async function triggerFormSubmittedNoEventWebhook(payload: string): Promise<void> {
const { webhook, responseId, form, redirect, responses } =
ZTriggerFormSubmittedNoEventWebhookPayloadSchema.parse(JSON.parse(payload));
const bookingFromResponse = await prisma.booking.findFirst({
where: {
routedFromRoutingFormReponse: {
@@ -81,7 +81,6 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
return typeof value === "string" && value.includes("@");
}
)?.value;
// Check for duplicate email in recent responses
const hasDuplicate =
emailValue &&
@@ -114,4 +113,24 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
}).catch((e) => {
console.error(`Error executing FORM_SUBMITTED_NO_EVENT webhook`, webhook, e);
});
// See if there are other incomplete booking actions
const incompleteBookingActions = await prisma.app_RoutingForms_IncompleteBookingActions.findMany({
where: {
formId: form.id,
},
});
if (incompleteBookingActions) {
for (const incompleteBookingAction of incompleteBookingActions) {
const actionType = incompleteBookingAction.actionType;
// Get action function
const bookingActionFunction = incompleteBookingActionFunctions[actionType];
if (emailValue) {
await bookingActionFunction(incompleteBookingAction, emailValue);
}
}
}
}
@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "IncompleteBookingActionType" AS ENUM ('SALESFORCE');
-- CreateTable
CREATE TABLE "App_RoutingForms_IncompleteBookingActions" (
"id" SERIAL NOT NULL,
"formId" TEXT NOT NULL,
"actionType" "IncompleteBookingActionType" NOT NULL,
"data" JSONB NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"credentialId" INTEGER,
CONSTRAINT "App_RoutingForms_IncompleteBookingActions_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "App_RoutingForms_IncompleteBookingActions" ADD CONSTRAINT "App_RoutingForms_IncompleteBookingActions_formId_fkey" FOREIGN KEY ("formId") REFERENCES "App_RoutingForms_Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+30 -15
View File
@@ -979,24 +979,25 @@ model App {
}
model App_RoutingForms_Form {
id String @id @default(cuid())
description String?
position Int @default(0)
routes Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
fields Json?
user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
description String?
position Int @default(0)
routes Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
fields Json?
user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade)
// This is the user who created the form and also the user who has read-write access to the form
// If teamId is set, the members of the team would also have access to form readOnly or read-write depending on their permission level as team member.
userId Int
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
responses App_RoutingForms_FormResponse[]
disabled Boolean @default(false)
userId Int
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
responses App_RoutingForms_FormResponse[]
disabled Boolean @default(false)
/// @zod.custom(imports.RoutingFormSettings)
settings Json?
settings Json?
incompleteBookingActions App_RoutingForms_IncompleteBookingActions[]
@@index([userId])
@@index([disabled])
@@ -1809,3 +1810,17 @@ model Watchlist {
@@unique([type, value])
@@index([type, value])
}
enum IncompleteBookingActionType {
SALESFORCE
}
model App_RoutingForms_IncompleteBookingActions {
id Int @id @default(autoincrement())
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
actionType IncompleteBookingActionType
data Json
enabled Boolean @default(true)
credentialId Int?
}