mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-07 06:12:31 -06:00
Merge branch 'main' of github.com:formbricks/formbricks into chore/next-14-downgrade
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) =>
|
||||
|
||||
@@ -33,6 +33,7 @@ export const responseSelection = {
|
||||
singleUseId: true,
|
||||
language: true,
|
||||
displayId: true,
|
||||
endingId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
apps/web/lib/utils/rate-limit.test.ts
Normal file
58
apps/web/lib/utils/rate-limit.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
@@ -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
|
||||
47
apps/web/modules/survey/follow-ups/lib/email.ts
Normal file
47
apps/web/modules/survey/follow-ups/lib/email.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
370
apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts
Normal file
370
apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
276
apps/web/modules/survey/follow-ups/lib/follow-ups.ts
Normal file
276
apps/web/modules/survey/follow-ups/lib/follow-ups.ts
Normal 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 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<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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
16
apps/web/modules/survey/follow-ups/types/follow-up.ts
Normal file
16
apps/web/modules/survey/follow-ups/types/follow-up.ts
Normal 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",
|
||||
}
|
||||
@@ -464,6 +464,7 @@ export function Survey({
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
displayId: surveyState.displayId,
|
||||
endingId: responseUpdate.endingId,
|
||||
hiddenFields: hiddenFieldsRecord,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user