From c52df00d3972bfa952ef9b59db2d6afad92c1a99 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Wed, 19 Jul 2023 12:30:31 +0200 Subject: [PATCH] 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 --- .../docs/client-api/create-response/index.mdx | 2 +- apps/web/app/api/pipeline/route.ts | 93 ++++++------------- .../v1/client/responses/[responseId]/route.ts | 8 +- apps/web/app/api/v1/client/responses/route.ts | 4 +- apps/web/app/api/v1/webhooks/route.ts | 2 +- apps/web/lib/email.ts | 10 +- apps/web/lib/pipelines.ts | 18 +--- .../lib/responses/questionResponseMapping.ts | 6 +- .../responses/[responseId]/index.ts | 38 +++----- .../[environmentId]/responses/index.ts | 43 +++------ packages/database/schema.prisma | 2 +- packages/database/zod-utils.ts | 5 +- packages/lib/constants.ts | 2 +- packages/lib/services/apiKey.ts | 4 - packages/lib/services/response.ts | 6 +- packages/lib/time.ts | 10 ++ packages/types/v1/pipelines.ts | 10 ++ packages/types/v1/responses.ts | 12 ++- 18 files changed, 112 insertions(+), 163 deletions(-) diff --git a/apps/formbricks-com/pages/docs/client-api/create-response/index.mdx b/apps/formbricks-com/pages/docs/client-api/create-response/index.mdx index c93d80f240..288200305a 100644 --- a/apps/formbricks-com/pages/docs/client-api/create-response/index.mdx +++ b/apps/formbricks-com/pages/docs/client-api/create-response/index.mdx @@ -39,7 +39,7 @@ export const meta = { }, ]} example={`{ - "personId: "clfqjny0v000ayzgsycx54a2c", + "personId": "clfqjny0v000ayzgsycx54a2c", "surveyId": "clfqz1esd0000yzah51trddn8", "finished": true, "data": { diff --git a/apps/web/app/api/pipeline/route.ts b/apps/web/app/api/pipeline/route.ts index 7ca15795cb..0eb3625b15 100644 --- a/apps/web/app/api/pipeline/route.ts +++ b/apps/web/app/api/pipeline/route.ts @@ -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); }) ); } diff --git a/apps/web/app/api/v1/client/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/responses/[responseId]/route.ts index adfa537e2a..cbaeae79fb 100644 --- a/apps/web/app/api/v1/client/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/client/responses/[responseId]/route.ts @@ -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, }); } diff --git a/apps/web/app/api/v1/client/responses/route.ts b/apps/web/app/api/v1/client/responses/route.ts index d6e7a0d5c0..fac01954e7 100644 --- a/apps/web/app/api/v1/client/responses/route.ts +++ b/apps/web/app/api/v1/client/responses/route.ts @@ -68,7 +68,7 @@ export async function POST(request: Request): Promise { 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 { event: "responseFinished", environmentId: survey.environmentId, surveyId: response.surveyId, - data: response, + response: response, }); } diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 8b03dfc85f..2acdb85d18 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -25,7 +25,7 @@ export async function GET() { if (error instanceof DatabaseError) { return responses.badRequestResponse(error.message); } - throw error; + return responses.internalServerErrorResponse(error.message); } } diff --git a/apps/web/lib/email.ts b/apps/web/lib/email.ts index bc80e33f38..618c4c4667 100644 --- a/apps/web/lib/email.ts +++ b/apps/web/lib/email.ts @@ -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(`

Hey 👋

Someone just completed your survey ${ survey.name }
diff --git a/apps/web/lib/pipelines.ts b/apps/web/lib/pipelines.ts index 91ad3bd6b6..a0b7111755 100644 --- a/apps/web/lib/pipelines.ts +++ b/apps/web/lib/pipelines.ts @@ -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}`); diff --git a/apps/web/lib/responses/questionResponseMapping.ts b/apps/web/lib/responses/questionResponseMapping.ts index f062d06a0b..724b76d864 100644 --- a/apps/web/lib/responses/questionResponseMapping.ts +++ b/apps/web/lib/responses/questionResponseMapping.ts @@ -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(), }); } diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts index eee3e20dde..2e9b757261 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts @@ -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 diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts index da47614809..ed1a639cbf 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts @@ -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, }); } diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index c504b71087..6ff9828142 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -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) diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 8764ab46d5..20e9b174a2 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -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"; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 56e8994df7..ff8d54d870 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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; diff --git a/packages/lib/services/apiKey.ts b/packages/lib/services/apiKey.ts index f671e73b9a..4c9d995bb8 100644 --- a/packages/lib/services/apiKey.ts +++ b/packages/lib/services/apiKey.ts @@ -42,10 +42,6 @@ export const getApiKeyFromKey = async (apiKey: string): Promise }, }); - if (!apiKeyData) { - throw new ResourceNotFoundError("API Key", apiKey); - } - return apiKeyData; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/lib/services/response.ts b/packages/lib/services/response.ts index af2ae66cc2..06aff35898 100644 --- a/packages/lib/services/response.ts +++ b/packages/lib/services/response.ts @@ -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 => { +export const createResponse = async (responseInput: Partial): Promise => { try { let person: TPerson | null = null; diff --git a/packages/lib/time.ts b/packages/lib/time.ts index cd73ad8904..c270b85829 100644 --- a/packages/lib/time.ts +++ b/packages/lib/time.ts @@ -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]); + } + } +} diff --git a/packages/types/v1/pipelines.ts b/packages/types/v1/pipelines.ts index 1fb9f258ef..c3450f07f1 100644 --- a/packages/types/v1/pipelines.ts +++ b/packages/types/v1/pipelines.ts @@ -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; + +export const ZPipelineInput = z.object({ + event: ZPipelineTrigger, + response: ZResponse, + environmentId: z.string(), + surveyId: z.string(), +}); + +export type TPipelineInput = z.infer; diff --git a/packages/types/v1/responses.ts b/packages/types/v1/responses.ts index b96beec200..83c4ef8a14 100644 --- a/packages/types/v1/responses.ts +++ b/packages/types/v1/responses.ts @@ -27,7 +27,16 @@ const ZResponseNote = z.object({ export type TResponseNote = z.infer; -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; + +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;