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;