From 6a002b900ff89fd4615e4e2f90439e45501c4973 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 3 Nov 2022 20:10:03 +0530 Subject: [PATCH] Send Email to Owner on Form Submission (#5261) Co-authored-by: alannnc Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/web/pages/apps/[slug]/[...pages].tsx | 5 +- apps/web/public/static/locales/en/common.json | 2 + .../routing-forms/components/SingleForm.tsx | 21 ++- .../emails/components/ResponseEmail.tsx | 52 ++++++++ .../routing-forms/emails/components/index.tsx | 1 + .../emails/templates/response-email.ts | 33 +++++ .../routing-forms/lib/getSerializableForm.ts | 9 ++ .../pages/form-edit/[...appPages].tsx | 13 +- .../pages/route-builder/[...appPages].tsx | 13 +- .../app-store/ee/routing-forms/trpc-router.ts | 123 +++++++++++------- .../ee/routing-forms/types/types.d.ts | 5 +- packages/emails/src/templates/index.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + packages/prisma/zod-utils.ts | 6 + packages/types/AppGetServerSideProps.d.ts | 7 +- 16 files changed, 241 insertions(+), 54 deletions(-) create mode 100644 packages/app-store/ee/routing-forms/emails/components/ResponseEmail.tsx create mode 100644 packages/app-store/ee/routing-forms/emails/components/index.tsx create mode 100644 packages/app-store/ee/routing-forms/emails/templates/response-email.ts create mode 100644 packages/prisma/migrations/20221028093727_add_routing_form_settings/migration.sql diff --git a/apps/web/pages/apps/[slug]/[...pages].tsx b/apps/web/pages/apps/[slug]/[...pages].tsx index f528391e25..80c3fd0f6e 100644 --- a/apps/web/pages/apps/[slug]/[...pages].tsx +++ b/apps/web/pages/apps/[slug]/[...pages].tsx @@ -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 diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d54472866a..87b5aa6175 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/ee/routing-forms/components/SingleForm.tsx b/packages/app-store/ee/routing-forms/components/SingleForm.tsx index cf245e0182..3c0d65fc14 100644 --- a/packages/app-store/ee/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/ee/routing-forms/components/SingleForm.tsx @@ -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 || ""} /> + +
+ { + return ( + onChange(val)} + /> + ); + }} + /> +
{!form._count?.responses && ( ; + response: Response; + subject: string; +} & Partial>) => { + return ( + +

+ + <>Manage this form + +

+ + } + title={form.name} + subtitle="New Response Received" + {...props}> + {Object.entries(response).map(([fieldId, fieldResponse]) => { + return ( + + ); + })} +
+ ); +}; diff --git a/packages/app-store/ee/routing-forms/emails/components/index.tsx b/packages/app-store/ee/routing-forms/emails/components/index.tsx new file mode 100644 index 0000000000..f31987110f --- /dev/null +++ b/packages/app-store/ee/routing-forms/emails/components/index.tsx @@ -0,0 +1 @@ +export { ResponseEmail } from "./ResponseEmail"; diff --git a/packages/app-store/ee/routing-forms/emails/templates/response-email.ts b/packages/app-store/ee/routing-forms/emails/templates/response-email.ts new file mode 100644 index 0000000000..1805b96b46 --- /dev/null +++ b/packages/app-store/ee/routing-forms/emails/templates/response-email.ts @@ -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; +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 { + 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, + }), + }; + } +} diff --git a/packages/app-store/ee/routing-forms/lib/getSerializableForm.ts b/packages/app-store/ee/routing-forms/lib/getSerializableForm.ts index aabf39c405..6edc814ef8 100644 --- a/packages/app-store/ee/routing-forms/lib/getSerializableForm.ts +++ b/packages/app-store/ee/routing-forms/lib/getSerializableForm.ts @@ -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(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 = { ...form, + settings: settings, fields: fieldsParsed.data, routes: routesParsed.data, createdAt: form.createdAt.toString(), diff --git a/packages/app-store/ee/routing-forms/pages/form-edit/[...appPages].tsx b/packages/app-store/ee/routing-forms/pages/form-edit/[...appPages].tsx index 29842e6dd8..7586df8fb8 100644 --- a/packages/app-store/ee/routing-forms/pages/form-edit/[...appPages].tsx +++ b/packages/app-store/ee/routing-forms/pages/form-edit/[...appPages].tsx @@ -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), }, }; diff --git a/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx index e3c3bc0ef6..906e863516 100644 --- a/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx @@ -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), }, }; diff --git a/packages/app-store/ee/routing-forms/trpc-router.ts b/packages/app-store/ee/routing-forms/trpc-router.ts index 4f88633e05..af454ab9a3 100644 --- a/packages/app-store/ee/routing-forms/trpc-router.ts +++ b/packages/app-store/ee/routing-forms/trpc-router.ts @@ -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 & { user: User }, "fields">, + response: Response +) { + const fieldResponsesByName: Record = {}; + + 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, + 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 = {}; - - 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, }, }); diff --git a/packages/app-store/ee/routing-forms/types/types.d.ts b/packages/app-store/ee/routing-forms/types/types.d.ts index 21f8b1b042..33b97c8cb7 100644 --- a/packages/app-store/ee/routing-forms/types/types.d.ts +++ b/packages/app-store/ee/routing-forms/types/types.d.ts @@ -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; export type Route = Routes[0]; export type SerializableForm = Omit< T, - "fields" | "routes" | "createdAt" | "updatedAt" + "fields" | "routes" | "createdAt" | "updatedAt" | "settings" > & { routes: Routes; fields: Fields; + settings: z.infer; createdAt: string; updatedAt: string; }; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index b1682fc23f..ee8fe10b19 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -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"; diff --git a/packages/prisma/migrations/20221028093727_add_routing_form_settings/migration.sql b/packages/prisma/migrations/20221028093727_add_routing_form_settings/migration.sql new file mode 100644 index 0000000000..c74a622a06 --- /dev/null +++ b/packages/prisma/migrations/20221028093727_add_routing_form_settings/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "App_RoutingForms_Form" ADD COLUMN "settings" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index a7e08e7180..8c96334d96 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9e17729bbb..fe4bd8ccc6 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -213,6 +213,12 @@ export const successRedirectUrl = z ]) .optional(); +export const RoutingFormSettings = z + .object({ + emailOwnerOnSubmission: z.boolean(), + }) + .nullable(); + export type ZodDenullish = T extends ZodNullable | ZodOptional ? ZodDenullish : T; diff --git a/packages/types/AppGetServerSideProps.d.ts b/packages/types/AppGetServerSideProps.d.ts index e2f1d5ee7c..0a31b856d0 100644 --- a/packages/types/AppGetServerSideProps.d.ts +++ b/packages/types/AppGetServerSideProps.d.ts @@ -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;