Merge branch 'main' of github.com:formbricks/formbricks into chore/next-14-downgrade

This commit is contained in:
Matthias Nannt
2025-05-10 12:20:13 +02:00
18 changed files with 920 additions and 504 deletions

View File

@@ -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<TOrganization> = {
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();
});
});
});

View File

@@ -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<void> => {
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<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
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;
};

View File

@@ -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) =>

View File

@@ -33,6 +33,7 @@ export const responseSelection = {
singleUseId: true,
language: true,
displayId: true,
endingId: true,
contact: {
select: {
id: true,

View File

@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
contactId,
surveyId,
displayId,
endingId,
finished,
data,
meta,
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
finished,
endingId,
data: data,
language: language,
...(contact?.id && {

View File

@@ -1,70 +1,99 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { loginLimiter, signupLimiter } from "./bucket";
import type { Mock } from "vitest";
// Mock constants
vi.mock("@/lib/constants", () => ({
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,
});
});
});

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -67,24 +67,24 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
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 = {

View File

@@ -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<void> => {
const emailHtmlBody = await render(
await FollowUpEmail({
html,
logoUrl,
attachResponseData,
survey,
response,
})
);
await sendEmail({
to,
replyTo: replyTo.join(", "),
subject,
html: emailHtmlBody,
});
};

View File

@@ -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: "<p>Test HTML Content</p>",
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: "<p>Test HTML Content</p>",
attachResponseData: true,
},
},
surveyId: "surveyid",
},
logoUrl: "https://example.com/custom-logo.png",
attachResponseData: false,
survey: {

View File

@@ -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<React.JSX.Element> {
const questions = attachResponseData ? getQuestionResponseMapping(survey, response) : [];
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
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 (
<Html>
@@ -56,13 +54,13 @@ export async function FollowUpEmail({
<Img alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={logoUrl} />
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={props.logoUrl} />
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left text-sm">
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(html, {
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https

View File

@@ -0,0 +1,47 @@
import { sendEmail } from "@/modules/email";
import { FollowUpEmail } from "@/modules/survey/follow-ups/components/follow-up-email";
import { render } from "@react-email/components";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const sendFollowUpEmail = async ({
followUp,
to,
replyTo,
survey,
response,
attachResponseData = false,
logoUrl,
}: {
followUp: TSurveyFollowUp;
to: string;
replyTo: string[];
attachResponseData: boolean;
survey: TSurvey;
response: TResponse;
logoUrl?: string;
}): Promise<void> => {
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,
});
};

View File

@@ -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();
}
});
});
});

View File

@@ -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<FollowUpResult> => {
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<Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: any }>> => {
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 followup 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<FollowUpResult> => {
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 },
});
}
};

View File

@@ -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",
}

View File

@@ -464,6 +464,7 @@ export function Survey({
},
variables: responseUpdate.variables,
displayId: surveyState.displayId,
endingId: responseUpdate.endingId,
hiddenFields: hiddenFieldsRecord,
});