diff --git a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.test.ts b/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.test.ts deleted file mode 100644 index ab1cbc9779..0000000000 --- a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - mockContactEmailFollowUp, - mockDirectEmailFollowUp, - mockEndingFollowUp, - mockEndingId2, - mockResponse, - mockResponseEmailFollowUp, - mockResponseWithContactQuestion, - mockSurvey, - mockSurveyWithContactQuestion, -} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock"; -import { sendFollowUpEmail } from "@/modules/email"; -import { describe, expect, test, vi } from "vitest"; -import { logger } from "@formbricks/logger"; -import { TOrganization } from "@formbricks/types/organizations"; -import { TResponse } from "@formbricks/types/responses"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up"; - -// Mock dependencies -vi.mock("@/modules/email", () => ({ - sendFollowUpEmail: vi.fn(), -})); - -vi.mock("@formbricks/logger", () => ({ - logger: { - error: vi.fn(), - }, -})); - -describe("Survey Follow Up", () => { - const mockOrganization: Partial = { - id: "org1", - name: "Test Org", - whitelabel: { - logoUrl: "https://example.com/logo.png", - }, - }; - - describe("evaluateFollowUp", () => { - test("sends email when to is a direct email address", async () => { - const followUpId = mockDirectEmailFollowUp.id; - const followUpAction = mockDirectEmailFollowUp.action; - - await evaluateFollowUp( - followUpId, - followUpAction, - mockSurvey, - mockResponse, - mockOrganization as TOrganization - ); - - expect(sendFollowUpEmail).toHaveBeenCalledWith({ - html: mockDirectEmailFollowUp.action.properties.body, - subject: mockDirectEmailFollowUp.action.properties.subject, - to: mockDirectEmailFollowUp.action.properties.to, - replyTo: mockDirectEmailFollowUp.action.properties.replyTo, - survey: mockSurvey, - response: mockResponse, - attachResponseData: true, - logoUrl: "https://example.com/logo.png", - }); - }); - - test("sends email when to is a question ID with valid email", async () => { - const followUpId = mockResponseEmailFollowUp.id; - const followUpAction = mockResponseEmailFollowUp.action; - - await evaluateFollowUp( - followUpId, - followUpAction, - mockSurvey as TSurvey, - mockResponse as TResponse, - mockOrganization as TOrganization - ); - - expect(sendFollowUpEmail).toHaveBeenCalledWith({ - html: mockResponseEmailFollowUp.action.properties.body, - subject: mockResponseEmailFollowUp.action.properties.subject, - to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to], - replyTo: mockResponseEmailFollowUp.action.properties.replyTo, - survey: mockSurvey, - response: mockResponse, - attachResponseData: true, - logoUrl: "https://example.com/logo.png", - }); - }); - - test("sends email when to is a question ID with valid email in array", async () => { - const followUpId = mockContactEmailFollowUp.id; - const followUpAction = mockContactEmailFollowUp.action; - - await evaluateFollowUp( - followUpId, - followUpAction, - mockSurveyWithContactQuestion, - mockResponseWithContactQuestion, - mockOrganization as TOrganization - ); - - expect(sendFollowUpEmail).toHaveBeenCalledWith({ - html: mockContactEmailFollowUp.action.properties.body, - subject: mockContactEmailFollowUp.action.properties.subject, - to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2], - replyTo: mockContactEmailFollowUp.action.properties.replyTo, - survey: mockSurveyWithContactQuestion, - response: mockResponseWithContactQuestion, - attachResponseData: true, - logoUrl: "https://example.com/logo.png", - }); - }); - - test("throws error when to value is not found in response data", async () => { - const followUpId = "followup1"; - const followUpAction = { - ...mockSurvey.followUps![0].action, - properties: { - ...mockSurvey.followUps![0].action.properties, - to: "nonExistentField", - }, - }; - - await expect( - evaluateFollowUp( - followUpId, - followUpAction, - mockSurvey as TSurvey, - mockResponse as TResponse, - mockOrganization as TOrganization - ) - ).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`); - }); - - test("throws error when email address is invalid", async () => { - const followUpId = mockResponseEmailFollowUp.id; - const followUpAction = mockResponseEmailFollowUp.action; - - const invalidResponse = { - ...mockResponse, - data: { - [mockResponseEmailFollowUp.action.properties.to]: "invalid-email", - }, - }; - - await expect( - evaluateFollowUp( - followUpId, - followUpAction, - mockSurvey, - invalidResponse, - mockOrganization as TOrganization - ) - ).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`); - }); - }); - - describe("sendSurveyFollowUps", () => { - test("skips follow-up when ending Id doesn't match", async () => { - const responseWithDifferentEnding = { - ...mockResponse, - endingId: mockEndingId2, - }; - - const mockSurveyWithEndingFollowUp: TSurvey = { - ...mockSurvey, - followUps: [mockEndingFollowUp], - }; - - const results = await sendSurveyFollowUps( - mockSurveyWithEndingFollowUp, - responseWithDifferentEnding as TResponse, - mockOrganization as TOrganization - ); - - expect(results).toEqual([ - { - followUpId: mockEndingFollowUp.id, - status: "skipped", - }, - ]); - expect(sendFollowUpEmail).not.toHaveBeenCalled(); - }); - - test("processes follow-ups and log errors", async () => { - const error = new Error("Test error"); - vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error); - - const mockSurveyWithFollowUps: TSurvey = { - ...mockSurvey, - followUps: [mockResponseEmailFollowUp], - }; - - const results = await sendSurveyFollowUps( - mockSurveyWithFollowUps, - mockResponse, - mockOrganization as TOrganization - ); - - expect(results).toEqual([ - { - followUpId: mockResponseEmailFollowUp.id, - status: "error", - error: "Test error", - }, - ]); - expect(logger.error).toHaveBeenCalledWith( - [`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`], - "Follow-up processing errors" - ); - }); - - test("successfully processes follow-ups", async () => { - vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined); - - const mockSurveyWithFollowUp: TSurvey = { - ...mockSurvey, - followUps: [mockDirectEmailFollowUp], - }; - - const results = await sendSurveyFollowUps( - mockSurveyWithFollowUp, - mockResponse, - mockOrganization as TOrganization - ); - - expect(results).toEqual([ - { - followUpId: mockDirectEmailFollowUp.id, - status: "success", - }, - ]); - expect(logger.error).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts b/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts deleted file mode 100644 index b430760922..0000000000 --- a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { sendFollowUpEmail } from "@/modules/email"; -import { z } from "zod"; -import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up"; -import { logger } from "@formbricks/logger"; -import { TOrganization } from "@formbricks/types/organizations"; -import { TResponse } from "@formbricks/types/responses"; -import { TSurvey } from "@formbricks/types/surveys/types"; - -type FollowUpResult = { - followUpId: string; - status: "success" | "error" | "skipped"; - error?: string; -}; - -export const evaluateFollowUp = async ( - followUpId: string, - followUpAction: TSurveyFollowUpAction, - survey: TSurvey, - response: TResponse, - organization: TOrganization -): Promise => { - const { properties } = followUpAction; - const { to, subject, body, replyTo } = properties; - const toValueFromResponse = response.data[to]; - const logoUrl = organization.whitelabel?.logoUrl || ""; - - // Check if 'to' is a direct email address (team member or user email) - const parsedEmailTo = z.string().email().safeParse(to); - if (parsedEmailTo.success) { - // 'to' is a valid email address, send email directly - await sendFollowUpEmail({ - html: body, - subject, - to: parsedEmailTo.data, - replyTo, - survey, - response, - attachResponseData: properties.attachResponseData, - logoUrl, - }); - return; - } - - // If not a direct email, check if it's a question ID or hidden field ID - if (!toValueFromResponse) { - throw new Error(`"To" value not found in response data for followup: ${followUpId}`); - } - - if (typeof toValueFromResponse === "string") { - // parse this string to check for an email: - const parsedResult = z.string().email().safeParse(toValueFromResponse); - if (parsedResult.data) { - // send email to this email address - await sendFollowUpEmail({ - html: body, - subject, - to: parsedResult.data, - replyTo, - logoUrl, - survey, - response, - attachResponseData: properties.attachResponseData, - }); - } else { - throw new Error(`Email address is not valid for followup: ${followUpId}`); - } - } else if (Array.isArray(toValueFromResponse)) { - const emailAddress = toValueFromResponse[2]; - if (!emailAddress) { - throw new Error(`Email address not found in response data for followup: ${followUpId}`); - } - const parsedResult = z.string().email().safeParse(emailAddress); - if (parsedResult.data) { - await sendFollowUpEmail({ - html: body, - subject, - to: parsedResult.data, - replyTo, - logoUrl, - survey, - response, - attachResponseData: properties.attachResponseData, - }); - } else { - throw new Error(`Email address is not valid for followup: ${followUpId}`); - } - } -}; - -export const sendSurveyFollowUps = async ( - survey: TSurvey, - response: TResponse, - organization: TOrganization -): Promise => { - const followUpPromises = survey.followUps.map(async (followUp): Promise => { - const { trigger } = followUp; - - // Check if we should skip this follow-up based on ending IDs - if (trigger.properties) { - const { endingIds } = trigger.properties; - const { endingId } = response; - - if (!endingId || !endingIds.includes(endingId)) { - return Promise.resolve({ - followUpId: followUp.id, - status: "skipped", - }); - } - } - - return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization) - .then(() => ({ - followUpId: followUp.id, - status: "success" as const, - })) - .catch((error) => ({ - followUpId: followUp.id, - status: "error" as const, - error: error instanceof Error ? error.message : "Something went wrong", - })); - }); - - const followUpResults = await Promise.all(followUpPromises); - - // Log all errors - const errors = followUpResults - .filter((result): result is FollowUpResult & { status: "error" } => result.status === "error") - .map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`); - - if (errors.length > 0) { - logger.error(errors, "Follow-up processing errors"); - } - - return followUpResults; -}; diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index bbc782f25c..5ecfe645ac 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -1,4 +1,3 @@ -import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up"; import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; @@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { convertDatesInObject } from "@/lib/time"; import { sendResponseFinishedEmail } from "@/modules/email"; -import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups"; +import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; import { PipelineTriggers, Webhook } from "@prisma/client"; import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; @@ -164,11 +164,15 @@ export const POST = async (request: Request) => { select: { email: true, locale: true }, }); - // send follow up emails - const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan); - - if (surveyFollowUpsPermission) { - await sendSurveyFollowUps(survey, response, organization); + if (survey.followUps?.length > 0) { + // send follow up emails + const followUpsResult = await sendFollowUpsForResponse(response.id); + if (!followUpsResult.ok) { + const { error: followUpsError } = followUpsResult; + if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) { + logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`); + } + } } const emailPromises = usersWithNotifications.map((user) => diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts index 14a93586ff..bf3b7531e2 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts @@ -33,6 +33,7 @@ export const responseSelection = { singleUseId: true, language: true, displayId: true, + endingId: true, contact: { select: { id: true, diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts index be1f02a486..7a2e25804c 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts @@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise ({ - ENTERPRISE_LICENSE_KEY: undefined, - REDIS_HTTP_URL: undefined, - LOGIN_RATE_LIMIT: { - interval: 15 * 60, - allowedPerInterval: 5, - }, - SIGNUP_RATE_LIMIT: { - interval: 60 * 60, - allowedPerInterval: 5, - }, - VERIFY_EMAIL_RATE_LIMIT: { - interval: 60 * 60, - allowedPerInterval: 5, - }, - FORGET_PASSWORD_RATE_LIMIT: { - interval: 60 * 60, - allowedPerInterval: 5, - }, - CLIENT_SIDE_API_RATE_LIMIT: { - interval: 60, - allowedPerInterval: 5, - }, - SHARE_RATE_LIMIT: { - interval: 60, - allowedPerInterval: 5, - }, - SYNC_USER_IDENTIFICATION_RATE_LIMIT: { - interval: 60, - allowedPerInterval: 5, - }, -})); +vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() })); -describe("Rate Limiters", () => { +describe("bucket middleware rate limiters", () => { beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); + const mockedRateLimit = rateLimit as unknown as Mock; + mockedRateLimit.mockImplementation((config) => config); }); - test("loginLimiter allows requests within limit", () => { - const token = "test-token-1"; - // Should not throw for first request - expect(() => loginLimiter(token)).not.toThrow(); + test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => { + const { loginLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.LOGIN_RATE_LIMIT.interval, + allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval, + }); + expect(loginLimiter).toEqual({ + interval: constants.LOGIN_RATE_LIMIT.interval, + allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval, + }); }); - test("loginLimiter throws when limit exceeded", () => { - const token = "test-token-2"; - // Make multiple requests to exceed the limit - for (let i = 0; i < 5; i++) { - expect(() => loginLimiter(token)).not.toThrow(); - } - // Next request should throw - expect(() => loginLimiter(token)).toThrow("Rate limit exceeded"); + test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => { + const { signupLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.SIGNUP_RATE_LIMIT.interval, + allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval, + }); + expect(signupLimiter).toEqual({ + interval: constants.SIGNUP_RATE_LIMIT.interval, + allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval, + }); }); - test("different limiters use different counters", () => { - const token = "test-token-3"; - // Exceed login limit - for (let i = 0; i < 5; i++) { - expect(() => loginLimiter(token)).not.toThrow(); - } - // Should throw for login - expect(() => loginLimiter(token)).toThrow("Rate limit exceeded"); - // Should still be able to use signup limiter - expect(() => signupLimiter(token)).not.toThrow(); + test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => { + const { verifyEmailLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval, + allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval, + }); + expect(verifyEmailLimiter).toEqual({ + interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval, + allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval, + }); + }); + + test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => { + const { forgotPasswordLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval, + allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval, + }); + expect(forgotPasswordLimiter).toEqual({ + interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval, + allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval, + }); + }); + + test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => { + const { clientSideApiEndpointsLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval, + allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval, + }); + expect(clientSideApiEndpointsLimiter).toEqual({ + interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval, + allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval, + }); + }); + + test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => { + const { shareUrlLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.SHARE_RATE_LIMIT.interval, + allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval, + }); + expect(shareUrlLimiter).toEqual({ + interval: constants.SHARE_RATE_LIMIT.interval, + allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval, + }); + }); + + test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => { + const { syncUserIdentificationLimiter } = await import("./bucket"); + expect(rateLimit).toHaveBeenCalledWith({ + interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval, + allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval, + }); + expect(syncUserIdentificationLimiter).toEqual({ + interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval, + allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval, + }); }); }); diff --git a/apps/web/app/middleware/bucket.ts b/apps/web/app/middleware/bucket.ts index d75934d6d0..eab92737ec 100644 --- a/apps/web/app/middleware/bucket.ts +++ b/apps/web/app/middleware/bucket.ts @@ -1,4 +1,3 @@ -import { rateLimit } from "@/app/middleware/rate-limit"; import { CLIENT_SIDE_API_RATE_LIMIT, FORGET_PASSWORD_RATE_LIMIT, @@ -8,6 +7,7 @@ import { SYNC_USER_IDENTIFICATION_RATE_LIMIT, VERIFY_EMAIL_RATE_LIMIT, } from "@/lib/constants"; +import { rateLimit } from "@/lib/utils/rate-limit"; export const loginLimiter = rateLimit({ interval: LOGIN_RATE_LIMIT.interval, diff --git a/apps/web/lib/utils/rate-limit.test.ts b/apps/web/lib/utils/rate-limit.test.ts new file mode 100644 index 0000000000..90c6bb1069 --- /dev/null +++ b/apps/web/lib/utils/rate-limit.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +describe("in-memory rate limiter", () => { + test("allows requests within limit and throws after limit", async () => { + const { rateLimit } = await import("./rate-limit"); + const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 }); + await expect(limiterFn("a")).resolves.toBeUndefined(); + await expect(limiterFn("a")).resolves.toBeUndefined(); + await expect(limiterFn("a")).rejects.toThrow("Rate limit exceeded"); + }); + + test("separate tokens have separate counts", async () => { + const { rateLimit } = await import("./rate-limit"); + const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 }); + await expect(limiterFn("x")).resolves.toBeUndefined(); + await expect(limiterFn("y")).resolves.toBeUndefined(); + await expect(limiterFn("x")).resolves.toBeUndefined(); + await expect(limiterFn("y")).resolves.toBeUndefined(); + }); +}); + +describe("redis rate limiter", () => { + beforeEach(async () => { + vi.resetModules(); + const constants = await vi.importActual("@/lib/constants"); + vi.doMock("@/lib/constants", () => ({ + ...constants, + REDIS_HTTP_URL: "http://redis", + })); + }); + + test("sets expire on first use and does not throw", async () => { + global.fetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 1 }) }) + .mockResolvedValueOnce({ ok: true }); + const { rateLimit } = await import("./rate-limit"); + const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 }); + await expect(limiter("t")).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith("http://redis/INCR/t"); + expect(fetch).toHaveBeenCalledWith("http://redis/EXPIRE/t/10"); + }); + + test("does not throw when redis INCR response not ok", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ ok: false }); + const { rateLimit } = await import("./rate-limit"); + const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 }); + await expect(limiter("t")).resolves.toBeUndefined(); + }); + + test("throws when INCR exceeds limit", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 3 }) }); + const { rateLimit } = await import("./rate-limit"); + const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 }); + await expect(limiter("t")).rejects.toThrow("Rate limit exceeded for IP: t"); + }); +}); diff --git a/apps/web/app/middleware/rate-limit.ts b/apps/web/lib/utils/rate-limit.ts similarity index 89% rename from apps/web/app/middleware/rate-limit.ts rename to apps/web/lib/utils/rate-limit.ts index a279a47760..3d07fa4282 100644 --- a/apps/web/app/middleware/rate-limit.ts +++ b/apps/web/lib/utils/rate-limit.ts @@ -1,4 +1,4 @@ -import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@/lib/constants"; +import { REDIS_HTTP_URL } from "@/lib/constants"; import { LRUCache } from "lru-cache"; import { logger } from "@formbricks/logger"; @@ -13,7 +13,7 @@ const inMemoryRateLimiter = (options: Options) => { ttl: options.interval * 1000, // converts to expected input of milliseconds }); - return (token: string) => { + return async (token: string) => { const currentUsage = tokenCache.get(token) ?? 0; if (currentUsage >= options.allowedPerInterval) { throw new Error("Rate limit exceeded"); @@ -40,12 +40,13 @@ const redisRateLimiter = (options: Options) => async (token: string) => { throw new Error(); } } catch (e) { + logger.error({ error: e }, "Rate limit exceeded"); throw new Error("Rate limit exceeded for IP: " + token); } }; export const rateLimit = (options: Options) => { - if (REDIS_HTTP_URL && ENTERPRISE_LICENSE_KEY) { + if (REDIS_HTTP_URL) { return redisRateLimiter(options); } else { return inMemoryRateLimiter(options); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 9edf547d57..09095ae90a 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -67,24 +67,24 @@ const handleAuth = async (request: NextRequest): Promise => { return null; }; -const applyRateLimiting = (request: NextRequest, ip: string) => { +const applyRateLimiting = async (request: NextRequest, ip: string) => { if (isLoginRoute(request.nextUrl.pathname)) { - loginLimiter(`login-${ip}`); + await loginLimiter(`login-${ip}`); } else if (isSignupRoute(request.nextUrl.pathname)) { - signupLimiter(`signup-${ip}`); + await signupLimiter(`signup-${ip}`); } else if (isVerifyEmailRoute(request.nextUrl.pathname)) { - verifyEmailLimiter(`verify-email-${ip}`); + await verifyEmailLimiter(`verify-email-${ip}`); } else if (isForgotPasswordRoute(request.nextUrl.pathname)) { - forgotPasswordLimiter(`forgot-password-${ip}`); + await forgotPasswordLimiter(`forgot-password-${ip}`); } else if (isClientSideApiRoute(request.nextUrl.pathname)) { - clientSideApiEndpointsLimiter(`client-side-api-${ip}`); + await clientSideApiEndpointsLimiter(`client-side-api-${ip}`); const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname); if (envIdAndUserId) { const { environmentId, userId } = envIdAndUserId; - syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`); + await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`); } } else if (isShareUrlRoute(request.nextUrl.pathname)) { - shareUrlLimiter(`share-${ip}`); + await shareUrlLimiter(`share-${ip}`); } }; @@ -153,7 +153,7 @@ export const middleware = async (originalRequest: NextRequest) => { if (ip) { try { - applyRateLimiting(request, ip); + await applyRateLimiting(request, ip); return nextResponseWithCustomHeader; } catch (e) { const apiError: ApiErrorResponseV2 = { diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index 8ccb691000..5cecef4828 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -33,7 +33,6 @@ import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email"; import { InviteEmail } from "./emails/invite/invite-email"; import { OnboardingInviteEmail } from "./emails/invite/onboarding-invite-email"; import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email"; -import { FollowUpEmail } from "./emails/survey/follow-up"; import { LinkSurveyEmail } from "./emails/survey/link-survey-email"; import { ResponseFinishedEmail } from "./emails/survey/response-finished-email"; import { NoLiveSurveyNotificationEmail } from "./emails/weekly-summary/no-live-survey-notification-email"; @@ -355,40 +354,3 @@ export const sendNoLiveSurveyNotificationEmail = async ( html, }); }; - -export const sendFollowUpEmail = async ({ - html, - replyTo, - subject, - to, - survey, - response, - attachResponseData = false, - logoUrl, -}: { - html: string; - subject: string; - to: string; - replyTo: string[]; - attachResponseData: boolean; - survey: TSurvey; - response: TResponse; - logoUrl?: string; -}): Promise => { - const emailHtmlBody = await render( - await FollowUpEmail({ - html, - logoUrl, - attachResponseData, - survey, - response, - }) - ); - - await sendEmail({ - to, - replyTo: replyTo.join(", "), - subject, - html: emailHtmlBody, - }); -}; diff --git a/apps/web/modules/email/emails/survey/follow-up.test.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-email.test.tsx similarity index 88% rename from apps/web/modules/email/emails/survey/follow-up.test.tsx rename to apps/web/modules/survey/follow-ups/components/follow-up-email.test.tsx index 8a58ebad18..67c008ab48 100644 --- a/apps/web/modules/email/emails/survey/follow-up.test.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-email.test.tsx @@ -5,7 +5,7 @@ import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; -import { FollowUpEmail } from "./follow-up"; +import { FollowUpEmail } from "./follow-up-email"; vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, @@ -24,7 +24,28 @@ vi.mock("@/modules/email/emails/lib/utils", () => ({ })); const defaultProps = { - html: "

Test HTML Content

", + followUp: { + id: "followupid", + createdAt: new Date(), + updatedAt: new Date(), + name: "Follow Up Email", + trigger: { + type: "response" as const, + properties: null, + }, + action: { + type: "send-email" as const, + properties: { + to: "", + from: "test@test.com", + replyTo: ["test@test.com"], + subject: "Subject", + body: "

Test HTML Content

", + attachResponseData: true, + }, + }, + surveyId: "surveyid", + }, logoUrl: "https://example.com/custom-logo.png", attachResponseData: false, survey: { diff --git a/apps/web/modules/email/emails/survey/follow-up.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx similarity index 84% rename from apps/web/modules/email/emails/survey/follow-up.tsx rename to apps/web/modules/survey/follow-ups/components/follow-up-email.tsx index 61fc0250ee..b85dbc53ee 100644 --- a/apps/web/modules/email/emails/survey/follow-up.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx @@ -17,6 +17,7 @@ import { } from "@react-email/components"; import dompurify from "isomorphic-dompurify"; import React from "react"; +import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -24,23 +25,20 @@ const fbLogoUrl = FB_LOGO_URL; const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email"; interface FollowUpEmailProps { - html: string; - logoUrl?: string; - attachResponseData: boolean; - survey: TSurvey; - response: TResponse; + readonly followUp: TSurveyFollowUp; + readonly logoUrl?: string; + readonly attachResponseData: boolean; + readonly survey: TSurvey; + readonly response: TResponse; } -export async function FollowUpEmail({ - html, - logoUrl, - attachResponseData, - survey, - response, -}: FollowUpEmailProps): Promise { - const questions = attachResponseData ? getQuestionResponseMapping(survey, response) : []; +export async function FollowUpEmail(props: FollowUpEmailProps): Promise { + const { properties } = props.followUp.action; + const { body } = properties; + + const questions = props.attachResponseData ? getQuestionResponseMapping(props.survey, props.response) : []; const t = await getTranslate(); - const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl; + const isDefaultLogo = !props.logoUrl || props.logoUrl === fbLogoUrl; return ( @@ -56,13 +54,13 @@ export async function FollowUpEmail({ Logo ) : ( - Logo + Logo )}
=> { + const { + action: { + properties: { subject }, + }, + } = followUp; + + const emailHtmlBody = await render( + await FollowUpEmail({ + followUp, + logoUrl, + attachResponseData, + survey, + response, + }) + ); + + await sendEmail({ + to, + replyTo: replyTo.join(", "), + subject, + html: emailHtmlBody, + }); +}; diff --git a/apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts b/apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts new file mode 100644 index 0000000000..3f8ff7712b --- /dev/null +++ b/apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts @@ -0,0 +1,370 @@ +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getResponse } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { sendFollowUpEmail } from "./email"; +import { sendFollowUpsForResponse } from "./follow-ups"; +import { getSurveyFollowUpsPermission } from "./utils"; + +// Mock all dependencies +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByEnvironmentId: vi.fn(), +})); + +vi.mock("@/lib/response/service", () => ({ + getResponse: vi.fn(), +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +vi.mock("./email", () => ({ + sendFollowUpEmail: vi.fn(), +})); + +vi.mock("./utils", () => ({ + getSurveyFollowUpsPermission: vi.fn(), +})); + +describe("Follow-ups", () => { + const mockResponse = { + id: "response1", + surveyId: "survey1", + data: { + email: "test@example.com", + question1: "answer1", + }, + endingId: "ending1", + } as unknown as TResponse; + + const mockSurvey = { + id: "survey1", + environmentId: "env1", + followUps: [ + { + id: "followup1", + action: { + type: "email", + properties: { + to: "email", + replyTo: "noreply@example.com", + attachResponseData: true, + }, + }, + trigger: { + type: "response", + properties: { + endingIds: ["ending1"], + }, + }, + }, + ], + } as unknown as TSurvey; + + const mockOrganization = { + id: "org1", + billing: { + plan: "scale", + limits: { + monthly: { miu: 1000, responses: 1000 }, + projects: 3, + }, + period: "monthly", + periodStart: new Date(), + stripeCustomerId: "cus123", + }, + whitelabel: { + logoUrl: "https://example.com/logo.png", + }, + } as unknown as TOrganization; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getResponse).mockResolvedValue(mockResponse); + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true); + vi.mocked(sendFollowUpEmail).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("sendFollowUpsForResponse", () => { + test("should successfully send follow-up emails", async () => { + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + followUpId: "followup1", + status: "success", + }); + expect(sendFollowUpEmail).toHaveBeenCalledWith({ + followUp: mockSurvey.followUps[0], + to: "test@example.com", + replyTo: "noreply@example.com", + survey: mockSurvey, + response: mockResponse, + attachResponseData: true, + logoUrl: "https://example.com/logo.png", + }); + } + }); + + test("should return error when response is not found", async () => { + vi.mocked(getResponse).mockResolvedValue(null); + + const result = await sendFollowUpsForResponse("nonexistentresponse"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toEqual({ + code: FollowUpSendError.RESPONSE_NOT_FOUND, + message: "Response not found", + meta: { responseId: "nonexistentresponse" }, + }); + } + }); + + test("should return error when survey is not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toEqual({ + code: FollowUpSendError.SURVEY_NOT_FOUND, + message: "Survey not found", + meta: { responseId: "response1", surveyId: "survey1" }, + }); + } + }); + + test("should return error when organization is not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toEqual({ + code: FollowUpSendError.ORG_NOT_FOUND, + message: "Organization not found", + meta: { responseId: "response1", surveyId: "survey1", environmentId: "env1" }, + }); + } + }); + + test("should return error when follow-ups are not allowed", async () => { + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toEqual({ + code: FollowUpSendError.FOLLOW_UP_NOT_ALLOWED, + message: "Survey follow-ups are not allowed for this organization", + meta: { responseId: "response1", surveyId: "survey1", organizationId: "org1" }, + }); + } + }); + + test("should skip follow-up when ending ID doesn't match", async () => { + const modifiedResponse = { + ...mockResponse, + endingId: "different-ending", + }; + + vi.mocked(getResponse).mockResolvedValue(modifiedResponse); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + followUpId: "followup1", + status: "skipped", + }); + + expect(sendFollowUpEmail).not.toHaveBeenCalled(); + } + }); + + test("should handle direct email address in follow-up", async () => { + const modifiedSurvey = { + ...mockSurvey, + followUps: [ + { + ...mockSurvey.followUps[0], + action: { + ...mockSurvey.followUps[0].action, + properties: { + ...mockSurvey.followUps[0].action.properties, + to: "direct@example.com", + }, + }, + }, + ], + }; + + vi.mocked(getSurvey).mockResolvedValue(modifiedSurvey); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + followUpId: "followup1", + status: "success", + }); + + expect(sendFollowUpEmail).toHaveBeenCalledWith({ + followUp: modifiedSurvey.followUps[0], + to: "direct@example.com", + replyTo: "noreply@example.com", + survey: modifiedSurvey, + response: mockResponse, + attachResponseData: true, + logoUrl: "https://example.com/logo.png", + }); + } + }); + + test("should handle invalid email address in response data", async () => { + const modifiedResponse = { + ...mockResponse, + data: { + email: "invalid-email", + }, + }; + + vi.mocked(getResponse).mockResolvedValue(modifiedResponse); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + followUpId: "followup1", + status: "error", + error: "Email address is not valid for followup: followup1", + }); + expect(sendFollowUpEmail).not.toHaveBeenCalled(); + } + }); + + test("should handle missing email value in response data", async () => { + const modifiedResponse = { + ...mockResponse, + data: {}, + }; + + vi.mocked(getSurvey).mockResolvedValue({ + ...mockSurvey, + followUps: [ + { + id: "followup1", + action: { + type: "email", + properties: { + to: "email", + replyTo: "noreply@example.com", + attachResponseData: true, + }, + }, + trigger: { + type: "response", + properties: { + endingIds: ["ending1"], + }, + }, + }, + ], + } as unknown as TSurvey); + + vi.mocked(getResponse).mockResolvedValue(modifiedResponse as unknown as any); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + followUpId: "followup1", + status: "error", + error: "To value not found in response data for followup: followup1", + }); + expect(sendFollowUpEmail).not.toHaveBeenCalled(); + } + }); + + test("should handle email sending error", async () => { + vi.mocked(getSurvey).mockResolvedValue({ + ...mockSurvey, + followUps: [ + { + id: "followup1", + action: { + type: "email", + properties: { + to: "hello@example.com", + replyTo: "noreply@example.com", + attachResponseData: true, + }, + }, + trigger: { + type: "response", + properties: { + endingIds: ["ending1"], + }, + }, + }, + ], + } as unknown as TSurvey); + + vi.mocked(sendFollowUpEmail).mockRejectedValue(new Error("Failed to send email")); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + followUpId: "followup1", + status: "error", + error: "Failed to send email", + }); + } + }); + + test("should return empty array when no follow-ups are configured", async () => { + const modifiedSurvey = { + ...mockSurvey, + followUps: [], + } as unknown as TSurvey; + + vi.mocked(getSurvey).mockResolvedValue(modifiedSurvey); + + const result = await sendFollowUpsForResponse("response1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual([]); + expect(sendFollowUpEmail).not.toHaveBeenCalled(); + } + }); + }); +}); diff --git a/apps/web/modules/survey/follow-ups/lib/follow-ups.ts b/apps/web/modules/survey/follow-ups/lib/follow-ups.ts new file mode 100644 index 0000000000..60aab3ab18 --- /dev/null +++ b/apps/web/modules/survey/follow-ups/lib/follow-ups.ts @@ -0,0 +1,276 @@ +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getResponse } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { rateLimit } from "@/lib/utils/rate-limit"; +import { validateInputs } from "@/lib/utils/validate"; +import { sendFollowUpEmail } from "@/modules/survey/follow-ups/lib/email"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { FollowUpResult, FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; +import { z } from "zod"; +import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; +import { logger } from "@formbricks/logger"; +import { ZId } from "@formbricks/types/common"; +import { Result, err } from "@formbricks/types/error-handlers"; +import { ValidationError } from "@formbricks/types/errors"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +const limiter = rateLimit({ + interval: 60 * 60, // 1 hour + allowedPerInterval: 50, // max 50 calls per org per hour +}); + +const evaluateFollowUp = async ( + followUp: TSurveyFollowUp, + survey: TSurvey, + response: TResponse, + organization: TOrganization +): Promise => { + try { + const { properties } = followUp.action; + const { to, replyTo } = properties; + const toValueFromResponse = response.data[to]; + const logoUrl = organization.whitelabel?.logoUrl ?? ""; + + // Check if 'to' is a direct email address (team member or user email) + const parsedEmailTo = z.string().email().safeParse(to); + if (parsedEmailTo.success) { + // 'to' is a valid email address, send email directly + await sendFollowUpEmail({ + followUp, + to: parsedEmailTo.data, + replyTo, + survey, + response, + attachResponseData: properties.attachResponseData, + logoUrl, + }); + + return { + followUpId: followUp.id, + status: "success" as const, + }; + } + + // If not a direct email, check if it's a question ID or hidden field ID + if (!toValueFromResponse) { + return { + followUpId: followUp.id, + status: "error", + error: `To value not found in response data for followup: ${followUp.id}`, + }; + } + + if (typeof toValueFromResponse === "string") { + // parse this string to check for an email: + const parsedResult = z.string().email().safeParse(toValueFromResponse); + if (parsedResult.success) { + // send email to this email address + await sendFollowUpEmail({ + followUp, + to: parsedResult.data, + replyTo, + logoUrl, + survey, + response, + attachResponseData: properties.attachResponseData, + }); + + return { + followUpId: followUp.id, + status: "success" as const, + }; + } + + return { + followUpId: followUp.id, + status: "error", + error: `Email address is not valid for followup: ${followUp.id}`, + }; + } else if (Array.isArray(toValueFromResponse)) { + const emailAddress = toValueFromResponse[2]; + if (!emailAddress) { + return { + followUpId: followUp.id, + status: "error", + error: `Email address not found in response data for followup: ${followUp.id}`, + }; + } + + const parsedResult = z.string().email().safeParse(emailAddress); + if (parsedResult.data) { + await sendFollowUpEmail({ + followUp, + to: parsedResult.data, + replyTo, + logoUrl, + survey, + response, + attachResponseData: properties.attachResponseData, + }); + + return { + followUpId: followUp.id, + status: "success" as const, + }; + } + + return { + followUpId: followUp.id, + status: "error", + error: `Email address is not valid for followup: ${followUp.id}`, + }; + } + + return { + followUpId: followUp.id, + status: "error", + error: "Something went wrong", + }; + } catch (error) { + return { + followUpId: followUp.id, + status: "error", + error: error instanceof Error ? error.message : "Something went wrong", + }; + } +}; + +/** + * Sends follow-up emails for a survey response. + * This is the main entry point for sending follow-ups - it handles all the logic internally + * and only requires a response ID. + */ +export const sendFollowUpsForResponse = async ( + responseId: string +): Promise> => { + try { + validateInputs([responseId, ZId]); + // Get the response first to get the survey ID + const response = await getResponse(responseId); + if (!response) { + return err({ + code: FollowUpSendError.RESPONSE_NOT_FOUND, + message: "Response not found", + meta: { responseId }, + }); + } + + const surveyId = response.surveyId; + const survey = await getSurvey(surveyId); + if (!survey) { + return err({ + code: FollowUpSendError.SURVEY_NOT_FOUND, + message: "Survey not found", + meta: { responseId, surveyId }, + }); + } + + // Get organization from survey's environmentId + const organization = await getOrganizationByEnvironmentId(survey.environmentId); + if (!organization) { + return err({ + code: FollowUpSendError.ORG_NOT_FOUND, + message: "Organization not found", + meta: { responseId, surveyId, environmentId: survey.environmentId }, + }); + } + + // Check if follow-ups are allowed for this organization + const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan); + if (!surveyFollowUpsPermission) { + return err({ + code: FollowUpSendError.FOLLOW_UP_NOT_ALLOWED, + message: "Survey follow-ups are not allowed for this organization", + meta: { responseId, surveyId, organizationId: organization.id }, + }); + } + + // Check rate limit + try { + await limiter(organization.id); + } catch { + return err({ + code: FollowUpSendError.RATE_LIMIT_EXCEEDED, + message: "Too many follow‐up requests; please wait a bit and try again.", + }); + } + + // If no follow-ups configured, return empty array + if (!survey.followUps?.length) { + return { + ok: true, + data: [], + }; + } + + // Process each follow-up + const followUpPromises = survey.followUps.map(async (followUp): Promise => { + const { trigger } = followUp; + + // Check if we should skip this follow-up based on ending IDs + if (trigger.properties) { + const { endingIds } = trigger.properties; + const { endingId } = response; + + if (!endingId || !endingIds.includes(endingId)) { + return { + followUpId: followUp.id, + status: "skipped", + }; + } + } + + return evaluateFollowUp(followUp, survey, response, organization); + }); + + const followUpResults = await Promise.all(followUpPromises); + + // Log all errors + const errors = followUpResults + .filter((result): result is FollowUpResult & { status: "error" } => result.status === "error") + .map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`); + + if (errors.length > 0) { + logger.error( + { + errors, + meta: { + responseId, + surveyId, + organizationId: organization.id, + }, + }, + "Follow-up processing errors" + ); + } + + return { + ok: true, + data: followUpResults, + }; + } catch (error) { + logger.error( + { + error, + meta: { responseId }, + }, + "Unexpected error while sending follow-ups" + ); + + if (error instanceof ValidationError) { + return err({ + code: FollowUpSendError.VALIDATION_ERROR, + message: error.message, + meta: { responseId }, + }); + } + + return err({ + code: FollowUpSendError.UNEXPECTED_ERROR, + message: "An unexpected error occurred while sending follow-ups", + meta: { responseId }, + }); + } +}; diff --git a/apps/web/modules/survey/follow-ups/types/follow-up.ts b/apps/web/modules/survey/follow-ups/types/follow-up.ts new file mode 100644 index 0000000000..39c3d28744 --- /dev/null +++ b/apps/web/modules/survey/follow-ups/types/follow-up.ts @@ -0,0 +1,16 @@ +export type FollowUpResult = { + followUpId: string; + status: "success" | "error" | "skipped"; + error?: string; +}; + +export enum FollowUpSendError { + VALIDATION_ERROR = "validation_error", + ORG_NOT_FOUND = "organization_not_found", + SURVEY_NOT_FOUND = "survey_not_found", + RESPONSE_NOT_FOUND = "response_not_found", + RESPONSE_SURVEY_MISMATCH = "response_survey_mismatch", + FOLLOW_UP_NOT_ALLOWED = "follow_up_not_allowed", + RATE_LIMIT_EXCEEDED = "rate_limit_exceeded", + UNEXPECTED_ERROR = "unexpected_error", +} diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index 271b97d830..9c89682f1e 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -464,6 +464,7 @@ export function Survey({ }, variables: responseUpdate.variables, displayId: surveyState.displayId, + endingId: responseUpdate.endingId, hiddenFields: hiddenFieldsRecord, });