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:
Hariom Balhara 2022-11-03 20:10:03 +05:30 committed by GitHub
parent d751cca0f4
commit 6a002b900f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 241 additions and 54 deletions

View File

@ -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

View File

@ -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",

View File

@ -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"

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export { ResponseEmail } from "./ResponseEmail";

View File

@ -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,
}),
};
}
}

View File

@ -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(),

View File

@ -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),
},
};

View File

@ -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),
},
};

View File

@ -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,
},
});

View File

@ -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;
};

View File

@ -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";

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "App_RoutingForms_Form" ADD COLUMN "settings" JSONB;

View File

@ -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 {

View File

@ -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;

View File

@ -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;