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:
co-authored by
Alex van Andel
parent
7a8b9ededb
commit
66b3e735d8
@@ -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;
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
+21
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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;
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user