mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 01:08:45 -05:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,6 +2,8 @@ export const getEventName = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "pageSubmission":
|
||||
return "Page Submission";
|
||||
case "formCompleted":
|
||||
return "Form Completed";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PipelineEvent" ADD VALUE 'FORM_COMPLETED';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PipelineType" ADD VALUE 'EMAIL_NOTIFICATION';
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user