Fix Email Notifications (#583)

* Fix email notifications not working properly

* Fix response notification not working

* fix response meta schema

* fix typo in docs

* improve error message in webhooks
This commit is contained in:
Matti Nannt
2023-07-19 12:30:31 +02:00
committed by GitHub
parent 503e7649e2
commit c52df00d39
18 changed files with 112 additions and 163 deletions
@@ -39,7 +39,7 @@ export const meta = {
},
]}
example={`{
"personId: "clfqjny0v000ayzgsycx54a2c",
"personId": "clfqjny0v000ayzgsycx54a2c",
"surveyId": "clfqz1esd0000yzah51trddn8",
"finished": true,
"data": {
+29 -64
View File
@@ -1,56 +1,43 @@
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { prisma } from "@formbricks/database";
import { NextResponse } from "next/server";
import { AttributeClass } from "@prisma/client";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { sendResponseFinishedEmail } from "@/lib/email";
import { prisma } from "@formbricks/database";
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { convertDatesInObject } from "@formbricks/lib/time";
import { Question } from "@formbricks/types/questions";
import { NotificationSettings } from "@formbricks/types/users";
import { ZPipelineInput } from "@formbricks/types/v1/pipelines";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { internalSecret, environmentId, surveyId, event, data } = await request.json();
if (!internalSecret) {
console.error("Pipeline: Missing internalSecret");
return new Response("Missing internalSecret", {
status: 400,
});
// check authentication with x-api-key header and CRON_SECRET env variable
if (headers().get("x-api-key") !== INTERNAL_SECRET) {
return responses.notAuthenticatedResponse();
}
if (!environmentId) {
console.error("Pipeline: Missing environmentId");
return new Response("Missing environmentId", {
status: 400,
});
}
if (!surveyId) {
console.error("Pipeline: Missing surveyId");
return new Response("Missing surveyId", {
status: 400,
});
}
if (!event) {
console.error("Pipeline: Missing event");
return new Response("Missing event", {
status: 400,
});
}
if (!data) {
console.error("Pipeline: Missing data");
return new Response("Missing data", {
status: 400,
});
}
if (internalSecret !== INTERNAL_SECRET) {
console.error("Pipeline: internalSecret doesn't match");
return new Response("Invalid internalSecret", {
status: 401,
});
const jsonInput = await request.json();
convertDatesInObject(jsonInput);
const inputValidation = ZPipelineInput.safeParse(jsonInput);
if (!inputValidation.success) {
console.error(inputValidation.error);
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, surveyId, event, response } = inputValidation.data;
// get all webhooks of this environment where event in triggers
const webhooks = await prisma.webhook.findMany({
where: {
environmentId,
triggers: {
hasSome: event,
has: event,
},
OR: [
{
@@ -75,7 +62,7 @@ export async function POST(request: Request) {
body: JSON.stringify({
webhookId: webhook.id,
event,
data,
data: response,
}),
});
})
@@ -136,32 +123,10 @@ export async function POST(request: Request) {
name: surveyData.name,
questions: JSON.parse(JSON.stringify(surveyData.questions)) as Question[],
};
// get person for response
let person: {
id: string;
attributes: { id: string; value: string; attributeClass: AttributeClass }[];
} | null;
if (data.personId) {
person = await prisma.person.findUnique({
where: {
id: data.personId,
},
select: {
id: true,
attributes: {
select: {
id: true,
value: true,
attributeClass: true,
},
},
},
});
}
// send email to all users
await Promise.all(
usersWithNotifications.map(async (user) => {
await sendResponseFinishedEmail(user.email, environmentId, survey, data, person);
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
})
);
}
@@ -68,11 +68,7 @@ export async function PUT(
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
// only send the updated fields
data: {
...response,
data: inputValidation.data.data,
},
response,
});
if (response.finished) {
@@ -82,7 +78,7 @@ export async function PUT(
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
data: response,
response: response,
});
}
@@ -68,7 +68,7 @@ export async function POST(request: Request): Promise<NextResponse> {
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
data: response,
response: response,
});
if (responseInput.finished) {
@@ -76,7 +76,7 @@ export async function POST(request: Request): Promise<NextResponse> {
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
data: response,
response: response,
});
}
+1 -1
View File
@@ -25,7 +25,7 @@ export async function GET() {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
return responses.internalServerErrorResponse(error.message);
}
}
+4 -6
View File
@@ -2,8 +2,7 @@ import { env } from "@/env.mjs";
import { getQuestionResponseMapping } from "@/lib/responses/questionResponseMapping";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Question } from "@formbricks/types/questions";
import { Response } from "@formbricks/types/responses";
import { AttributeClass } from "@prisma/client";
import { TResponse } from "@formbricks/types/v1/responses";
import { withEmailTemplate } from "./email-template";
import { createInviteToken, createToken } from "./jwt";
@@ -120,16 +119,15 @@ export const sendResponseFinishedEmail = async (
email: string,
environmentId: string,
survey: { id: string; name: string; questions: Question[] },
response: Response,
person: { id: string; attributes: { id: string; value: string; attributeClass: AttributeClass }[] } | null
response: TResponse
) => {
const personEmail = person?.attributes?.find((a) => a.attributeClass?.name === "email")?.value;
const personEmail = response.person?.attributes["email"];
await sendEmail({
to: email,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail || env.MAIL_FROM,
replyTo: personEmail?.toString() || env.MAIL_FROM,
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
survey.name
}</strong><br/>
+4 -14
View File
@@ -1,28 +1,18 @@
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
export async function sendToPipeline({
event,
surveyId,
environmentId,
data,
}: {
event: TPipelineTrigger;
surveyId: string;
environmentId: string;
data: any;
}) {
export async function sendToPipeline({ event, surveyId, environmentId, response }: TPipelineInput) {
return fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": INTERNAL_SECRET,
},
body: JSON.stringify({
internalSecret: INTERNAL_SECRET,
environmentId: environmentId,
surveyId: surveyId,
event,
data,
response,
}),
}).catch((error) => {
console.error(`Error sending event to pipeline: ${error}`);
@@ -1,9 +1,9 @@
import { Question } from "@formbricks/types/questions";
import { Response } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/v1/responses";
export const getQuestionResponseMapping = (
survey: { questions: Question[] },
response: Response
response: TResponse
): { question: string; answer: string }[] => {
const questionResponseMapping: { question: string; answer: string }[] = [];
@@ -12,7 +12,7 @@ export const getQuestionResponseMapping = (
questionResponseMapping.push({
question: question.headline,
answer,
answer: answer.toString(),
});
}
@@ -1,6 +1,9 @@
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
import { updateResponse } from "@formbricks/lib/services/response";
import { sendToPipeline } from "@/lib/pipelines";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
@@ -42,15 +45,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
...response.data,
};
// update response
const responseData = await prisma.response.update({
where: {
id: responseId,
},
data: {
...{ ...response, data: newResponseData },
},
});
const updatedResponse = await updateResponse(responseId, { ...response, data: newResponseData });
// send response update to pipeline
// don't await to not block the response
@@ -62,31 +57,24 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
body: JSON.stringify({
internalSecret: INTERNAL_SECRET,
environmentId,
surveyId: responseData.surveyId,
surveyId: updatedResponse.surveyId,
event: "responseUpdated",
data: { id: responseId, ...response },
}),
response: updatedResponse,
} as TPipelineInput),
});
if (response.finished) {
// send response to pipeline
// don't await to not block the response
fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
internalSecret: INTERNAL_SECRET,
environmentId,
surveyId: responseData.surveyId,
event: "responseFinished",
data: responseData,
}),
sendToPipeline({
environmentId,
surveyId: updatedResponse.surveyId,
event: "responseFinished",
response: updatedResponse,
});
}
return res.json(responseData);
return res.json({ message: "Response updated" });
}
// Unknown HTTP Method
@@ -1,8 +1,9 @@
import { sendToPipeline } from "@/lib/pipelines";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { createResponse } from "@formbricks/lib/services/response";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
@@ -95,39 +96,25 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
}
// create new response
const responseData = await prisma.response.create(createBody);
const responseData = await createResponse(createBody);
// send response to pipeline
// don't await to not block the response
fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
internalSecret: INTERNAL_SECRET,
environmentId,
surveyId,
event: "responseCreated",
data: responseData,
}),
sendToPipeline({
environmentId,
surveyId,
event: "responseCreated",
response: responseData,
});
if (response.finished) {
// send response to pipeline
// don't await to not block the response
fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
internalSecret: INTERNAL_SECRET,
environmentId,
surveyId,
event: "responseFinished",
data: responseData,
}),
sendToPipeline({
environmentId,
surveyId,
event: "responseFinished",
response: responseData,
});
}
+1 -1
View File
@@ -268,7 +268,7 @@ model EventClass {
description String?
type EventType
events Event[]
/// @zod.custom(imports.ZEventClassNoCodeConfig)
/// @zod.custom(imports.ZActionClassNoCodeConfig)
/// [EventClassNoCodeConfig]
noCodeConfig Json?
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
+2 -3
View File
@@ -1,10 +1,9 @@
import z from "zod";
export const ZEventProperties = z.record(z.string());
export { ZEventClassNoCodeConfig } from "@formbricks/types/v1/eventClasses";
export { ZActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
export { ZResponseData, ZResponsePersonAttributes } from "@formbricks/types/v1/responses";
export const ZResponseMeta = z.record(z.union([z.string(), z.number()]));
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";
export { ZSurveyQuestions, ZSurveyThankYouCard, ZSurveyClosedMessage } from "@formbricks/types/v1/surveys";
+1 -1
View File
@@ -16,5 +16,5 @@ export const WEBAPP_URL =
"http://localhost:3000";
// Other
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET;
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || "";
export const CRON_SECRET = process.env.CRON_SECRET;
-4
View File
@@ -42,10 +42,6 @@ export const getApiKeyFromKey = async (apiKey: string): Promise<TApiKey | null>
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("API Key", apiKey);
}
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
+3 -3
View File
@@ -1,12 +1,12 @@
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TPerson } from "@formbricks/types/v1/people";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import { cache } from "react";
import "server-only";
import { getPerson, transformPrismaPerson } from "./person";
import { cache } from "react";
import { TPerson } from "@formbricks/types/v1/people";
const responseSelection = {
id: true,
@@ -63,7 +63,7 @@ const responseSelection = {
},
};
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
export const createResponse = async (responseInput: Partial<TResponseInput>): Promise<TResponse> => {
try {
let person: TPerson | null = null;
+10
View File
@@ -94,3 +94,13 @@ export const getTodaysDateFormatted = (seperator: string) => {
return formattedDate;
};
export function convertDatesInObject(obj: any) {
for (let key in obj) {
if (typeof obj[key] === "string" && !isNaN(Date.parse(obj[key]))) {
obj[key] = new Date(obj[key]);
} else if (typeof obj[key] === "object" && obj[key] !== null) {
convertDatesInObject(obj[key]);
}
}
}
+10
View File
@@ -1,5 +1,15 @@
import { z } from "zod";
import { ZResponse } from "./responses";
export const ZPipelineTrigger = z.enum(["responseFinished", "responseCreated", "responseUpdated"]);
export type TPipelineTrigger = z.infer<typeof ZPipelineTrigger>;
export const ZPipelineInput = z.object({
event: ZPipelineTrigger,
response: ZResponse,
environmentId: z.string(),
surveyId: z.string(),
});
export type TPipelineInput = z.infer<typeof ZPipelineInput>;
+11 -1
View File
@@ -27,7 +27,16 @@ const ZResponseNote = z.object({
export type TResponseNote = z.infer<typeof ZResponseNote>;
const ZResponse = z.object({
export const ZResponseMeta = z.object({
userAgent: z.object({
browser: z.string().optional(),
os: z.string().optional(),
}),
});
export type TResponseMeta = z.infer<typeof ZResponseMeta>;
export const ZResponse = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
@@ -45,6 +54,7 @@ const ZResponse = z.object({
data: ZResponseData,
notes: z.array(ZResponseNote),
tags: z.array(ZTag),
meta: ZResponseMeta.nullable(),
});
export type TResponse = z.infer<typeof ZResponse>;