Send Email to Owner on Form Submission (#5261)
Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
d751cca0f4
commit
6a002b900f
|
@ -9,6 +9,8 @@ import { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
|
|||
import { AppProps } from "@lib/app-providers";
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
type AppPageType = {
|
||||
getServerSideProps: AppGetServerSideProps;
|
||||
// A component than can accept any properties
|
||||
|
@ -128,7 +130,8 @@ export async function getServerSideProps(
|
|||
appPages: string[];
|
||||
}>,
|
||||
prisma,
|
||||
user
|
||||
user,
|
||||
ssrInit
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
|
|
|
@ -1225,6 +1225,8 @@
|
|||
"exchange_authentication_ntlm": "NTLM authentication",
|
||||
"exchange_compression": "GZip compression",
|
||||
"routing_forms_description": "You can see all forms and routes you have created here.",
|
||||
"routing_forms_send_email_owner": "Send Email to Owner",
|
||||
"routing_forms_send_email_owner_description": "Sends an email to the owner when the form is submitted",
|
||||
"add_new_form": "Add new form",
|
||||
"form_description": "Create your form to route a booker",
|
||||
"copy_link_to_form": "Copy link to form",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { useEffect } from "react";
|
||||
import { useForm, UseFormReturn } from "react-hook-form";
|
||||
import { useForm, UseFormReturn, Controller } from "react-hook-form";
|
||||
|
||||
import useApp from "@calcom/lib/hooks/useApp";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -10,6 +10,7 @@ import { Form } from "@calcom/ui/form/fields";
|
|||
import { showToast, DropdownMenuSeparator } from "@calcom/ui/v2";
|
||||
import { ButtonGroup, TextAreaField, TextField, Tooltip, Button, VerticalDivider } from "@calcom/ui/v2";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import SettingsToggle from "@calcom/ui/v2/core/SettingsToggle";
|
||||
import { ShellMain } from "@calcom/ui/v2/core/Shell";
|
||||
import Banner from "@calcom/ui/v2/core/banner";
|
||||
|
||||
|
@ -187,6 +188,7 @@ type SingleFormComponentProps = {
|
|||
|
||||
function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const hookForm = useForm({
|
||||
defaultValues: form,
|
||||
|
@ -241,6 +243,23 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
|||
{...hookForm.register("description")}
|
||||
defaultValue={form.description || ""}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<Controller
|
||||
name="settings.emailOwnerOnSubmission"
|
||||
control={hookForm.control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("routing_forms_send_email_owner")}
|
||||
description={t("routing_forms_send_email_owner_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(val) => onChange(val)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!form._count?.responses && (
|
||||
<Banner
|
||||
className="mt-6"
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import { BaseEmailHtml, Info } from "@calcom/emails/src/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { Response } from "../../types/types";
|
||||
import { App_RoutingForms_Form } from ".prisma/client";
|
||||
|
||||
export const ResponseEmail = ({
|
||||
form,
|
||||
response,
|
||||
...props
|
||||
}: {
|
||||
form: Pick<App_RoutingForms_Form, "id" | "name">;
|
||||
response: Response;
|
||||
subject: string;
|
||||
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>) => {
|
||||
return (
|
||||
<BaseEmailHtml
|
||||
callToAction={
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Roboto, Helvetica, sans-serif",
|
||||
fontSize: "16px",
|
||||
fontWeight: 500,
|
||||
lineHeight: "0px",
|
||||
textAlign: "left",
|
||||
color: "#3e3e3e",
|
||||
}}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<a href={`${WEBAPP_URL}/apps/routing-forms/form-edit/${form.id}`} style={{ color: "#3e3e3e" }}>
|
||||
<>Manage this form</>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
title={form.name}
|
||||
subtitle="New Response Received"
|
||||
{...props}>
|
||||
{Object.entries(response).map(([fieldId, fieldResponse]) => {
|
||||
return (
|
||||
<Info
|
||||
withSpacer
|
||||
key={fieldId}
|
||||
label={fieldResponse.label}
|
||||
description={
|
||||
fieldResponse.value instanceof Array ? fieldResponse.value.join(",") : fieldResponse.value
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BaseEmailHtml>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ResponseEmail } from "./ResponseEmail";
|
|
@ -0,0 +1,33 @@
|
|||
import { renderEmail } from "@calcom/emails";
|
||||
import BaseEmail from "@calcom/emails/templates/_base-email";
|
||||
|
||||
import { Response } from "../../types/types";
|
||||
import { App_RoutingForms_Form } from ".prisma/client";
|
||||
|
||||
type Form = Pick<App_RoutingForms_Form, "id" | "name">;
|
||||
export default class ResponseEmail extends BaseEmail {
|
||||
response: Response;
|
||||
toAddresses: string[];
|
||||
form: Form;
|
||||
constructor({ toAddresses, response, form }: { form: Form; toAddresses: string[]; response: Response }) {
|
||||
super();
|
||||
this.form = form;
|
||||
this.response = response;
|
||||
this.toAddresses = toAddresses;
|
||||
}
|
||||
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
const toAddresses = this.toAddresses;
|
||||
const subject = `${this.form.name} has a new response`;
|
||||
return {
|
||||
from: `Cal.com <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject,
|
||||
html: renderEmail("ResponseEmail", {
|
||||
form: this.form,
|
||||
response: this.response,
|
||||
subject,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { SerializableForm } from "../types/types";
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
|
||||
|
@ -13,10 +15,17 @@ export function getSerializableForm<TForm extends App_RoutingForms_Form>(form: T
|
|||
if (!fieldsParsed.success) {
|
||||
throw new Error("Error parsing fields");
|
||||
}
|
||||
const settings = RoutingFormSettings.parse(
|
||||
form.settings || {
|
||||
// Would have really loved to do it using zod. But adding .default(true) throws type error in prisma/zod/app_routingforms_form.ts
|
||||
emailOwnerOnSubmission: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Ideally we shouldb't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
|
||||
const serializableForm: SerializableForm<TForm> = {
|
||||
...form,
|
||||
settings: settings,
|
||||
fields: fieldsParsed.data,
|
||||
routes: routesParsed.data,
|
||||
createdAt: form.createdAt.toString(),
|
||||
|
|
|
@ -6,7 +6,12 @@ import { UseFormReturn } from "react-hook-form";
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
|
||||
import {
|
||||
AppGetServerSidePropsContext,
|
||||
AppPrisma,
|
||||
AppUser,
|
||||
AppSsrInit,
|
||||
} from "@calcom/types/AppGetServerSideProps";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button, EmptyScreen, SelectField, TextAreaField, TextField, Shell } from "@calcom/ui/v2";
|
||||
import { BooleanToggleGroupField } from "@calcom/ui/v2/core/form/BooleanToggleGroup";
|
||||
|
@ -300,8 +305,10 @@ FormEditPage.getLayout = (page: React.ReactElement) => {
|
|||
export const getServerSideProps = async function getServerSideProps(
|
||||
context: AppGetServerSidePropsContext,
|
||||
prisma: AppPrisma,
|
||||
user: AppUser
|
||||
user: AppUser,
|
||||
ssrInit: AppSsrInit
|
||||
) {
|
||||
const ssr = await ssrInit(context);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
|
@ -349,6 +356,8 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
}
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
|
||||
form: getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,7 +6,12 @@ import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-bu
|
|||
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
|
||||
import {
|
||||
AppGetServerSidePropsContext,
|
||||
AppPrisma,
|
||||
AppUser,
|
||||
AppSsrInit,
|
||||
} from "@calcom/types/AppGetServerSideProps";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button, TextField, SelectWithValidation as Select, TextArea, Shell } from "@calcom/ui/v2";
|
||||
|
@ -463,8 +468,11 @@ RouteBuilder.getLayout = (page: React.ReactElement) => {
|
|||
export const getServerSideProps = async function getServerSideProps(
|
||||
context: AppGetServerSidePropsContext,
|
||||
prisma: AppPrisma,
|
||||
user: AppUser
|
||||
user: AppUser,
|
||||
ssrInit: AppSsrInit
|
||||
) {
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
|
@ -513,6 +521,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
form: getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,16 +1,77 @@
|
|||
import { Prisma, WebhookTriggerEvents } from "@prisma/client";
|
||||
import { App_RoutingForms_Form, Prisma, User, WebhookTriggerEvents } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import { createProtectedRouter, createRouter } from "@calcom/trpc/server/createRouter";
|
||||
import { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import ResponseEmail from "./emails/templates/response-email";
|
||||
import { getSerializableForm } from "./lib/getSerializableForm";
|
||||
import { isAllowed } from "./lib/isAllowed";
|
||||
import { Response, SerializableForm } from "./types/types";
|
||||
import { zodFields, zodRoutes } from "./zod";
|
||||
|
||||
async function onFormSubmission(
|
||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||
response: Response
|
||||
) {
|
||||
const fieldResponsesByName: Record<string, typeof response[keyof typeof response]["value"]> = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
// 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 fieldResponsesByName);
|
||||
fieldResponsesByName[key] = fieldResponse.value;
|
||||
}
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: form.user.id,
|
||||
// It isn't an eventType webhook
|
||||
eventTypeId: -1,
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
};
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) => {
|
||||
sendGenericWebhookPayload(
|
||||
webhook.secret,
|
||||
"FORM_SUBMITTED",
|
||||
new Date().toISOString(),
|
||||
webhook,
|
||||
fieldResponsesByName
|
||||
).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
if (form.settings?.emailOwnerOnSubmission) {
|
||||
logger.debug(
|
||||
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
|
||||
);
|
||||
await sendResponseEmail(form, response, form.user.email);
|
||||
}
|
||||
}
|
||||
|
||||
const sendResponseEmail = async (
|
||||
form: Pick<App_RoutingForms_Form, "id" | "name">,
|
||||
response: Response,
|
||||
ownerEmail: string
|
||||
) => {
|
||||
try {
|
||||
const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response });
|
||||
await email.sendEmail();
|
||||
} catch (e) {
|
||||
logger.error("Error sending response email", e);
|
||||
}
|
||||
};
|
||||
|
||||
const app_RoutingForms = createRouter()
|
||||
.merge(
|
||||
"public.",
|
||||
|
@ -41,24 +102,21 @@ const app_RoutingForms = createRouter()
|
|||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
const fieldsParsed = zodFields.safeParse(form.fields);
|
||||
if (!fieldsParsed.success) {
|
||||
// This should not be possible normally as before saving the form it is verified by zod
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
const fields = fieldsParsed.data;
|
||||
|
||||
if (!fields) {
|
||||
const serializableForm = getSerializableForm(form);
|
||||
if (!serializableForm.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
const missingFields = fields
|
||||
const serializableFormWithFields = {
|
||||
...serializableForm,
|
||||
fields: serializableForm.fields,
|
||||
};
|
||||
|
||||
const missingFields = serializableFormWithFields.fields
|
||||
.filter((field) => !(field.required ? response[field.id]?.value : true))
|
||||
.map((f) => f.label);
|
||||
|
||||
|
@ -68,7 +126,7 @@ const app_RoutingForms = createRouter()
|
|||
message: `Missing required fields ${missingFields.join(", ")}`,
|
||||
});
|
||||
}
|
||||
const invalidFields = fields
|
||||
const invalidFields = serializableFormWithFields.fields
|
||||
.filter((field) => {
|
||||
const fieldValue = response[field.id]?.value;
|
||||
// The field isn't required at this point. Validate only if it's set
|
||||
|
@ -94,39 +152,12 @@ const app_RoutingForms = createRouter()
|
|||
});
|
||||
}
|
||||
|
||||
const fieldResponsesByName: Record<string, typeof response[keyof typeof response]["value"]> = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
// Use the label lowercased as the key to identify a field.
|
||||
const key =
|
||||
fields.find((f) => f.id === fieldId)?.identifier ||
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||
fieldResponsesByName[key] = fieldResponse.value;
|
||||
}
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: form.user.id,
|
||||
// It isn't an eventType webhook
|
||||
eventTypeId: -1,
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
};
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) => {
|
||||
sendGenericWebhookPayload(
|
||||
webhook.secret,
|
||||
"FORM_SUBMITTED",
|
||||
new Date().toISOString(),
|
||||
webhook,
|
||||
fieldResponsesByName
|
||||
).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
});
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
return await prisma.app_RoutingForms_FormResponse.create({
|
||||
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: input,
|
||||
});
|
||||
|
||||
await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response);
|
||||
return dbFormResponse;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
|
@ -201,9 +232,10 @@ const app_RoutingForms = createRouter()
|
|||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
}),
|
||||
async resolve({ ctx: { user, prisma }, input }) {
|
||||
const { name, id, description, disabled, addFallback, duplicateFrom } = input;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom } = input;
|
||||
if (!(await isAllowed({ userId: user.id, formId: id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
|
@ -284,6 +316,7 @@ const app_RoutingForms = createRouter()
|
|||
fields: fields,
|
||||
name: name,
|
||||
description,
|
||||
settings: settings === null ? Prisma.JsonNull : settings,
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import z from "zod";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
|
||||
export type Response = Record<
|
||||
|
@ -17,10 +19,11 @@ export type Routes = z.infer<typeof zodRoutes>;
|
|||
export type Route = Routes[0];
|
||||
export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
|
||||
T,
|
||||
"fields" | "routes" | "createdAt" | "updatedAt"
|
||||
"fields" | "routes" | "createdAt" | "updatedAt" | "settings"
|
||||
> & {
|
||||
routes: Routes;
|
||||
fields: Fields;
|
||||
settings: z.infer<typeof RoutingFormSettings>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
|
|
@ -18,3 +18,4 @@ export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
|
|||
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
|
||||
export { TeamInviteEmail } from "./TeamInviteEmail";
|
||||
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
|
||||
export * from "@calcom/app-store/ee/routing-forms/emails/components";
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "App_RoutingForms_Form" ADD COLUMN "settings" JSONB;
|
|
@ -518,6 +518,8 @@ model App_RoutingForms_Form {
|
|||
userId Int
|
||||
responses App_RoutingForms_FormResponse[]
|
||||
disabled Boolean @default(false)
|
||||
/// @zod.custom(imports.RoutingFormSettings)
|
||||
settings Json?
|
||||
}
|
||||
|
||||
model App_RoutingForms_FormResponse {
|
||||
|
|
|
@ -213,6 +213,12 @@ export const successRedirectUrl = z
|
|||
])
|
||||
.optional();
|
||||
|
||||
export const RoutingFormSettings = z
|
||||
.object({
|
||||
emailOwnerOnSubmission: z.boolean(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export type ZodDenullish<T extends ZodTypeAny> = T extends ZodNullable<infer U> | ZodOptional<infer U>
|
||||
? ZodDenullish<U>
|
||||
: T;
|
||||
|
|
|
@ -3,14 +3,17 @@ import { CalendsoSessionUser } from "next-auth";
|
|||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type AppUser = CalendsoSessionUser | undefined;
|
||||
export type AppPrisma = typeof prisma;
|
||||
export type AppGetServerSidePropsContext = GetServerSidePropsContext<{
|
||||
appPages: string[];
|
||||
}>;
|
||||
|
||||
export type AppSsrInit = ssrInit;
|
||||
export type AppGetServerSideProps = (
|
||||
context: AppGetServerSidePropsContext,
|
||||
prisma: AppPrisma,
|
||||
user: AppUser
|
||||
user: AppUser,
|
||||
ssrInit: AppSsrInit
|
||||
) => GetServerSidePropsResult;
|
||||
|
|
Loading…
Reference in New Issue
Block a user