mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 16:59:35 -05:00
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:
@@ -39,7 +39,7 @@ export const meta = {
|
||||
},
|
||||
]}
|
||||
example={`{
|
||||
"personId: "clfqjny0v000ayzgsycx54a2c",
|
||||
"personId": "clfqjny0v000ayzgsycx54a2c",
|
||||
"surveyId": "clfqz1esd0000yzah51trddn8",
|
||||
"finished": true,
|
||||
"data": {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function GET() {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+13
-25
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user