Feat/email notification after submission (#89)

* feat: Email notifications for each submission

* feat: add email notification pipeline

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Francois Disubi
2022-10-25 14:44:59 +01:00
committed by GitHub
parent 4590654ace
commit eee3ea5ed3
14 changed files with 338 additions and 66 deletions
@@ -3,9 +3,11 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { createPipeline, usePipelines } from "../../lib/pipelines";
import Modal from "../Modal";
import { webhook, WebhookSettings } from "./webhook";
import { webhook } from "./webhook";
import { emailNotification } from "./emailNotification";
import PipelineSettings from "./PipelineSettings";
const availablePipelines = [webhook];
const availablePipelines = [webhook, emailNotification];
const getEmptyPipeline = () => {
return { name: "", type: null, events: [], data: {} };
@@ -50,7 +52,7 @@ export default function AddPipelineModal({ open, setOpen }) {
</h2>
{availablePipelines.map((pipeline) => (
<div
className="border-ui-gray-light w-full border bg-white shadow sm:rounded"
className="border-ui-gray-light mb-5 w-full border bg-white shadow sm:rounded"
key={pipeline.title}>
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">{pipeline.title}</h3>
@@ -75,7 +77,7 @@ export default function AddPipelineModal({ open, setOpen }) {
</>
) : (
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
{typeId === "WEBHOOK" ? <WebhookSettings pipeline={pipeline} setPipeline={setPipeline} /> : null}
<PipelineSettings typeId={typeId} pipeline={pipeline} setPipeline={setPipeline} />
<div className="pt-5">
<div className="flex justify-end">
<button
@@ -0,0 +1,16 @@
import { EmailNotificationSettings } from "./emailNotification";
import { WebhookSettings } from "./webhook";
const PipelineSettings = ({ typeId, pipeline, setPipeline }) => {
switch (typeId) {
case "WEBHOOK":
return <WebhookSettings pipeline={pipeline} setPipeline={setPipeline} />;
break;
case "EMAIL_NOTIFICATION":
return <EmailNotificationSettings pipeline={pipeline} setPipeline={setPipeline} />;
default:
return <></>;
}
};
export default PipelineSettings;
@@ -1,7 +1,7 @@
import { persistPipeline, usePipeline, usePipelines } from "../../lib/pipelines";
import Loading from "../Loading";
import Modal from "../Modal";
import { WebhookSettings } from "./webhook";
import PipelineSettings from "./PipelineSettings";
export default function UpdatePipelineModal({ open, setOpen, formId, pipelineId }) {
const { pipeline, isLoadingPipeline, mutatePipeline } = usePipeline(formId, pipelineId);
@@ -25,9 +25,11 @@ export default function UpdatePipelineModal({ open, setOpen, formId, pipelineId
<Loading />
) : (
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
{pipeline.type === "WEBHOOK" ? (
<WebhookSettings pipeline={pipeline} setPipeline={(p) => mutatePipeline(p, false)} />
) : null}
<PipelineSettings
typeId={pipeline.type}
pipeline={pipeline}
setPipeline={(p) => mutatePipeline(p, false)}
/>
<div className="pt-5">
<div className="flex justify-end">
<button
@@ -0,0 +1,142 @@
const eventTypes = [
{
id: "PAGE_SUBMISSION",
name: "Page Submission",
description: "every time a form page is submitted (partial submission)",
},
{
id: "FORM_COMPLETED",
name: "Form completed",
description: "each time the form is fully completed (total submission)",
},
];
export function EmailNotificationSettings({ pipeline, setPipeline }) {
const toggleEvent = (eventId) => {
const newPipeline = JSON.parse(JSON.stringify(pipeline));
const eventIdx = newPipeline.events.indexOf(eventId);
if (eventIdx !== -1) {
newPipeline.events.splice(eventIdx, 1);
} else {
newPipeline.events.push(eventId);
}
setPipeline(newPipeline);
};
const updateField = (field, value, parent = null) => {
const newPipeline = JSON.parse(JSON.stringify(pipeline));
if (parent) {
newPipeline[parent][field] = value;
} else {
newPipeline[field] = value;
}
setPipeline(newPipeline);
};
return (
<div className="space-y-8 divide-y divide-gray-200">
<div>
<h2 className="text-ui-gray-dark mb-3 text-xl font-bold">Configure Email notifications</h2>
<p className="mt-1 text-sm text-gray-500">
Configure Email notifications. To learn more about how webhooks work, please check out our docs.
</p>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Pipeline Name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
value={pipeline.name || ""}
onChange={(e) => updateField("name", e.target.value)}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
required
/>
</div>
</div>
<div className="sm:col-span-4">
<label htmlFor="endpointUrl" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
type="email"
// pattern="/^[^@]+@[^.]+\..+$/"
onInvalid={(e: any) => e.target.setCustomValidity("please provide a valid email")}
onInput={(e: any) => e.target.setCustomValidity("")}
name="email"
id="email"
value={pipeline.data.email || ""}
onChange={(e) => updateField("email", e.target.value, "data")}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
required
/>
</div>
<p className="mt-2 text-xs text-gray-500" id="email-description">
The email address that will receive notifications when the form/page is completed
</p>
</div>
</div>
</div>
<div className="pt-8">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Advanced Settings</h3>
<p className="mt-1 text-sm text-gray-500">Set up this webhook to fit your needs.</p>
</div>
<div className="mt-6">
<fieldset>
<legend className="sr-only">Events</legend>
<div className="text-base font-medium text-gray-900" aria-hidden="true">
Events
</div>
<div className="mt-4 space-y-4">
{eventTypes.map((eventType) => (
<div key={eventType.id}>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
id={eventType.id}
name={eventType.name}
type="checkbox"
checked={pipeline.events.includes(eventType.id)}
onChange={() => toggleEvent(eventType.id)}
className="h-4 w-4 rounded-sm border-gray-300 text-red-600 focus:ring-red-500"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor={eventType.id} className="font-medium text-gray-700">
{eventType.name}
</label>
<p className="text-gray-500">{eventType.description}</p>
</div>
</div>
</div>
))}
</div>
</fieldset>
</div>
<div className="mt-6">
<fieldset>
<legend className="sr-only">Conditions</legend>
<div className="text-base font-medium text-gray-900" aria-hidden="true">
Conditions
</div>
<div className="mt-4 space-y-4">
<div className="rounded-sm border border-gray-100 bg-gray-50 px-2 py-5">
<p className="flex justify-center text-xs text-gray-600">
conditional data piping coming soon
</p>
</div>
</div>
</fieldset>
</div>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
export const emailNotification = {
typeId: "EMAIL_NOTIFICATION",
title: "Email Notification",
description: "Get an email notification when your form is completed",
};
export * from "./SettingsComponent";
// export * from "./handler";
+71 -16
View File
@@ -1,8 +1,9 @@
import { handleWebhook } from "../components/pipelines/webhook";
import { sendFormSubmissionEmail, sendPageSubmissionEmail } from "./email";
import { capturePosthogEvent } from "./posthog";
import { prisma } from "@formbricks/database";
import { sendTelemetry } from "./telemetry";
import { ApiEvent } from "./types";
import { ApiEvent, Schema } from "./types";
type validationError = {
status: number;
@@ -15,9 +16,7 @@ export const validateEvents = (events: ApiEvent[]): validationError | undefined
}
for (const event of events) {
if (
!["createSubmissionSession", "pageSubmission", "submissionCompleted", "updateSchema"].includes(
event.type
)
!["createSubmissionSession", "pageSubmission", "formCompleted", "updateSchema"].includes(event.type)
) {
return {
status: 400,
@@ -29,31 +28,76 @@ export const validateEvents = (events: ApiEvent[]): validationError | undefined
};
export const processApiEvent = async (event: ApiEvent, formId) => {
const form = await prisma.form.findUnique({
where: {
id: formId,
},
select: {
owner: true,
schema: true,
id: true,
createdAt: true,
updatedAt: true,
noCodeForm: true,
name: true,
formType: true,
},
});
// save submission
if (event.type === "pageSubmission") {
const data = event.data;
const schema = form.schema as Schema;
const { pageName } = event.data;
const pages = schema.pages.filter((page) => page.type === "form");
const indexOfPage = pages.findIndex((page) => page.name === pageName);
// const owner = form.owner;
await prisma.sessionEvent.create({
data: {
type: "pageSubmission",
data: {
pageName: data.pageName,
submission: data.submission,
pageName: event.data.pageName,
submission: event.data.submission,
},
submissionSession: { connect: { id: data.submissionSessionId } },
submissionSession: { connect: { id: event.data.submissionSessionId } },
},
});
const form = await prisma.form.findUnique({
where: {
id: formId,
},
});
capturePosthogEvent(form.ownerId, "pageSubmission received", {
capturePosthogEvent(form.owner.id, "pageSubmission received", {
formId,
formType: form.formType,
});
sendTelemetry("pageSubmission received");
} else if (event.type === "submissionCompleted") {
// TODO
if (indexOfPage === pages.length - 1) {
processApiEvent(
{
...event,
type: "formCompleted",
data: {
submissionSessionId: event.data.submissionSessionId,
},
},
formId
);
}
} else if (event.type === "formCompleted") {
await prisma.sessionEvent.create({
data: {
type: "formCompleted",
data: {},
submissionSession: { connect: { id: event.data.submissionSessionId } },
},
});
capturePosthogEvent(form.owner.id, "pageSubmission received", {
formId,
formType: form.formType,
});
sendTelemetry("formCompleted received");
} else if (event.type === "updateSchema") {
const data = { schema: event.data, updatedAt: new Date() };
await prisma.form.update({
@@ -79,5 +123,16 @@ export const processApiEvent = async (event: ApiEvent, formId) => {
if (pipeline.type === "WEBHOOK") {
handleWebhook(pipeline, event);
}
if (pipeline.type === "EMAIL_NOTIFICATION") {
if (!pipeline.data.hasOwnProperty("email")) return;
const { email } = pipeline.data.valueOf() as { email: string };
if (event.type === "pageSubmission" && pipeline.events.includes("PAGE_SUBMISSION")) {
await sendPageSubmissionEmail(email, form.name, pipeline.formId);
} else if (event.type === "formCompleted" && pipeline.events.includes("FORM_COMPLETED")) {
await sendFormSubmissionEmail(email, form.name, pipeline.formId);
}
}
}
};
+21
View File
@@ -75,3 +75,24 @@ export const sendPasswordResetNotifyEmail = async (user) => {
Your snoopForms Team`,
});
};
export const sendPageSubmissionEmail = async (email: string, formName: string, formId: string) => {
await sendEmail({
to: email,
subject: `${formName} new page submission`,
html: `someone just filled out a page of ${formName}.<br/>
Click <a href="${process.env.NEXTAUTH_URL}/forms/${formId}/results/responses">here</a> to see new submission`,
});
};
export const sendFormSubmissionEmail = async (email: string, formName: string, formId: string) => {
await sendEmail({
to: email,
subject: `${formName} new submission`,
html: `Hey, someone just filled out ${formName}.<br/>
<br/>
Click <a href="${process.env.NEXTAUTH_URL}/forms/${formId}/results/responses">here</a> to see new submission`,
});
};
+2
View File
@@ -2,6 +2,8 @@ export const getEventName = (eventType: string) => {
switch (eventType) {
case "pageSubmission":
return "Page Submission";
case "formCompleted":
return "Form Completed";
default:
return eventType;
}
+7 -5
View File
@@ -87,7 +87,7 @@ export type SubmissionSummaryOption = {
summary: number;
};
export type pageSubmissionEvent = {
export type submissionEvent = {
id: string;
createdAt: string;
updatedAt: string;
@@ -99,12 +99,14 @@ export type pageSubmissionEvent = {
};
};
export type submissionCompletedEvent = {
export type formCompletedEvent = {
id: string;
createdAt: string;
updatedAt: string;
type: "submissionCompleted";
data: { [key: string]: string };
type: "formCompleted";
data: {
submissionSessionId: string;
};
};
export type updateSchemaEvent = {
@@ -115,7 +117,7 @@ export type updateSchemaEvent = {
data: Schema;
};
export type ApiEvent = pageSubmissionEvent | submissionCompletedEvent | updateSchemaEvent;
export type ApiEvent = submissionEvent | formCompletedEvent | updateSchemaEvent;
export type WebhookEvent = Event & { formId: string; timestamp: string };
+11
View File
@@ -4,6 +4,8 @@ import { CodeBracketSquareIcon, PlusIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
import { SiAirtable, SiGoogle, SiNotion, SiSlack, SiZapier } from "react-icons/si";
import { AiOutlineMail } from "react-icons/ai";
import BaseLayoutManagement from "../../../components/layout/BaseLayoutManagement";
import EmptyPageFiller from "../../../components/layout/EmptyPageFiller";
import LimitedWidth from "../../../components/layout/LimitedWidth";
@@ -28,6 +30,15 @@ const libs = [
icon: CodeBracketSquareIcon,
action: () => {},
},
{
id: "email notification",
name: "Email Notification",
href: "#",
comingSoon: false,
bgColor: "bg-ui-gray-light",
icon: AiOutlineMail,
action: () => {},
},
{
id: "Notion",
name: "Notion",
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "PipelineEvent" ADD VALUE 'FORM_COMPLETED';
-- AlterEnum
ALTER TYPE "PipelineType" ADD VALUE 'EMAIL_NOTIFICATION';
+39 -37
View File
@@ -14,58 +14,60 @@ enum FormType {
enum PipelineType {
WEBHOOK
EMAIL_NOTIFICATION
}
enum PipelineEvent {
PAGE_SUBMISSION
FORM_COMPLETED
}
model Form {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId Int
formType FormType @default(NOCODE)
name String @default("")
schema Json @default("{}")
submissionSessions SubmissionSession[]
pipelines Pipeline[]
noCodeForm NoCodeForm?
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId Int
formType FormType @default(NOCODE)
name String @default("")
schema Json @default("{}")
submissionSessions SubmissionSession[]
pipelines Pipeline[]
noCodeForm NoCodeForm?
}
model NoCodeForm {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String @unique
blocks Json @default("[]")
blocksDraft Json @default("[]")
published Boolean @default(false)
closed Boolean @default(false)
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String @unique
blocks Json @default("[]")
blocksDraft Json @default("[]")
published Boolean @default(false)
closed Boolean @default(false)
}
model Pipeline {
id String @id @default(uuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type PipelineType
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
enabled Boolean @default(false)
events PipelineEvent[]
data Json @default("{}")
id String @id @default(uuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type PipelineType
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
enabled Boolean @default(false)
events PipelineEvent[]
data Json @default("{}")
}
model SubmissionSession {
id String @id @default(uuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
events SessionEvent[]
id String @id @default(uuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
events SessionEvent[]
}
model SessionEvent {
@@ -90,4 +92,4 @@ model User {
forms Form[]
@@map(name: "users")
}
}
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PipelineType" ADD VALUE 'EMAIL_NOTIFICATION';
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PipelineEvent" ADD VALUE 'FORM_COMPLETED';