mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-27 00:40:29 -06:00
Compare commits
9 Commits
fix-editor
...
chore/next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f9f47bbf | ||
|
|
f95e039e89 | ||
|
|
fcbb97010c | ||
|
|
9481f2087b | ||
|
|
f954bbb30d | ||
|
|
515b5fc311 | ||
|
|
77f0e344c3 | ||
|
|
6be46b16b2 | ||
|
|
35b2356a31 |
@@ -191,8 +191,7 @@ UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
@@ -200,9 +199,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -102,6 +102,8 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
|
||||
expect(result).toEqual([mockTransformedSurvey]);
|
||||
@@ -116,6 +118,8 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: expect.any(Object),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
|
||||
@@ -21,6 +21,10 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import createRedisHandler from "@fortedigital/nextjs-cache-handler/redis-strings";
|
||||
import { CacheHandler } from "@neshca/cache-handler";
|
||||
import createLruHandler from "@neshca/cache-handler/local-lru";
|
||||
import createRedisHandler from "@neshca/cache-handler/redis-strings";
|
||||
import { createClient } from "redis";
|
||||
|
||||
// Function to create a timeout promise
|
||||
|
||||
@@ -205,7 +205,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
|
||||
export const REDIS_DEFAULT_TTL = env.REDIS_DEFAULT_TTL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ export const env = createEnv({
|
||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
REDIS_DEFAULT_TTL: z.string().optional(),
|
||||
REDIS_HTTP_URL: z.string().optional(),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
POSTHOG_API_HOST: z.string().optional(),
|
||||
@@ -163,7 +162,6 @@ export const env = createEnv({
|
||||
OIDC_ISSUER: process.env.OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
REDIS_DEFAULT_TTL: process.env.REDIS_DEFAULT_TTL,
|
||||
REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
|
||||
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
|
||||
PRIVACY_URL: process.env.PRIVACY_URL,
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { after } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ContentLayout } from "./components/content-layout";
|
||||
|
||||
@@ -118,9 +117,8 @@ export const InvitePage = async (props: InvitePageProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
after(async () => {
|
||||
await createMembershipAction();
|
||||
});
|
||||
// Execute the server action immediately
|
||||
await createMembershipAction();
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { after } from "next/server";
|
||||
import fetch from "node-fetch";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -65,9 +64,8 @@ const setPreviousResult = async (previousResult: {
|
||||
}
|
||||
)();
|
||||
|
||||
after(() => {
|
||||
revalidateTag(PREVIOUS_RESULTS_CACHE_TAG_KEY);
|
||||
});
|
||||
// Revalidate the cache tag immediately
|
||||
revalidateTag(PREVIOUS_RESULTS_CACHE_TAG_KEY);
|
||||
};
|
||||
|
||||
const fetchLicenseForE2ETesting = async (): Promise<{
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -16,20 +16,23 @@ const getHostname = (url) => {
|
||||
|
||||
const nextConfig = {
|
||||
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
|
||||
cacheHandler: require.resolve("./cache-handler.mjs"),
|
||||
//cacheMaxMemorySize: 0, // disable default in-memory caching
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
productionBrowserSourceMaps: false,
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],
|
||||
localeDetection: false,
|
||||
defaultLocale: "en-US",
|
||||
},
|
||||
experimental: {},
|
||||
transpilePackages: ["@formbricks/database"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -282,11 +285,6 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// set custom cache handler
|
||||
if (process.env.CUSTOM_CACHE_DISABLED !== "1") {
|
||||
nextConfig.cacheHandler = require.resolve("./cache-handler.mjs");
|
||||
}
|
||||
|
||||
// set actions allowed origins
|
||||
if (process.env.WEBAPP_URL) {
|
||||
nextConfig.experimental.serverActions = {
|
||||
@@ -302,22 +300,25 @@ nextConfig.images.remotePatterns.push({
|
||||
});
|
||||
|
||||
const sentryOptions = {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
org: "formbricks",
|
||||
project: "formbricks-cloud",
|
||||
org: "formbricks",
|
||||
project: "formbricks-cloud",
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: true,
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: true,
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
};
|
||||
|
||||
const exportConfig = (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
|
||||
const exportConfig =
|
||||
process.env.SENTRY_DSN && process.env.NODE_ENV === "production"
|
||||
? withSentryConfig(nextConfig, sentryOptions)
|
||||
: nextConfig;
|
||||
|
||||
export default exportConfig;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next coverage",
|
||||
"dev": "next dev -p 3000 --turbopack",
|
||||
"go": "next dev -p 3000 --turbopack",
|
||||
"dev": "next dev -p 3000",
|
||||
"go": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"build:dev": "next build",
|
||||
"start": "next start",
|
||||
@@ -32,6 +32,7 @@
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@fortedigital/nextjs-cache-handler": "1.2.0",
|
||||
"@hookform/resolvers": "5.0.1",
|
||||
"@intercom/messenger-js-sdk": "0.0.14",
|
||||
"@json2csv/node": "7.0.6",
|
||||
@@ -101,7 +102,7 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"mime-types": "3.0.1",
|
||||
"nanoid": "5.1.5",
|
||||
"next": "15.3.1",
|
||||
"next": "14.2.28",
|
||||
"next-auth": "4.24.11",
|
||||
"next-safe-action": "7.10.8",
|
||||
"node-fetch": "3.3.2",
|
||||
@@ -111,8 +112,8 @@
|
||||
"posthog-js": "1.240.0",
|
||||
"posthog-node": "4.17.1",
|
||||
"prismjs": "1.30.0",
|
||||
"qrcode": "1.5.4",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-confetti": "6.4.0",
|
||||
@@ -158,8 +159,8 @@
|
||||
"autoprefixer": "10.4.21",
|
||||
"dotenv": "16.5.0",
|
||||
"postcss": "8.5.3",
|
||||
"ts-node": "10.9.2",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"ts-node": "10.9.2",
|
||||
"vite": "6.3.5",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.1.3",
|
||||
|
||||
@@ -16,13 +16,13 @@ services:
|
||||
- 8025:8025 # web ui
|
||||
- 1025:1025 # smtp server
|
||||
|
||||
redis:
|
||||
image: redis:7.0.11
|
||||
command: "redis-server"
|
||||
valkey:
|
||||
image: valkey/valkey:8.1.1
|
||||
command: "valkey-server"
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
- valkey-data:/data
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
|
||||
@@ -31,15 +31,15 @@ services:
|
||||
- MINIO_ROOT_USER=devminio
|
||||
- MINIO_ROOT_PASSWORD=devminio123
|
||||
ports:
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # Console
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # Console
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
redis-data:
|
||||
valkey-data:
|
||||
driver: local
|
||||
minio-data:
|
||||
driver: local
|
||||
|
||||
@@ -175,7 +175,6 @@ x-environment: &environment
|
||||
|
||||
# Set the below to use Redis for Next Caching (default is In-Memory from Next Cache)
|
||||
# REDIS_URL:
|
||||
# REDIS_DEFAULT_TTL:
|
||||
|
||||
# Set the below to use for Rate Limiting (default us In-Memory LRU Cache)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
@@ -63,7 +63,6 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
|
||||
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
|
||||
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
|
||||
| CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | |
|
||||
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
|
||||
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
|
||||
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
|
||||
|
||||
@@ -126,7 +126,6 @@ Configure Redis by adding the following environment variables to your instances:
|
||||
|
||||
```sh env
|
||||
REDIS_URL=redis://your-redis-host:6379
|
||||
REDIS_DEFAULT_TTL=86400
|
||||
REDIS_HTTP_URL=http://your-redis-host:8000
|
||||
```
|
||||
|
||||
|
||||
@@ -464,6 +464,7 @@ export function Survey({
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
displayId: surveyState.displayId,
|
||||
endingId: responseUpdate.endingId,
|
||||
hiddenFields: hiddenFieldsRecord,
|
||||
});
|
||||
|
||||
|
||||
166
pnpm-lock.yaml
generated
166
pnpm-lock.yaml
generated
@@ -151,6 +151,9 @@ importers:
|
||||
'@formbricks/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@fortedigital/nextjs-cache-handler':
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)
|
||||
'@hookform/resolvers':
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1(react-hook-form@7.56.2(react@19.1.0))
|
||||
@@ -264,7 +267,7 @@ importers:
|
||||
version: 0.0.38(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@sentry/nextjs':
|
||||
specifier: 9.15.0
|
||||
version: 9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8)
|
||||
version: 9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8)
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: 0.13.4
|
||||
version: 0.13.4(arktype@2.1.20)(typescript@5.8.3)(zod@3.24.4)
|
||||
@@ -359,14 +362,14 @@ importers:
|
||||
specifier: 5.1.5
|
||||
version: 5.1.5
|
||||
next:
|
||||
specifier: 15.3.1
|
||||
version: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
specifier: 14.2.28
|
||||
version: 14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next-auth:
|
||||
specifier: 4.24.11
|
||||
version: 4.24.11(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 4.24.11(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next-safe-action:
|
||||
specifier: 7.10.8
|
||||
version: 7.10.8(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.24.4)
|
||||
version: 7.10.8(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.24.4)
|
||||
node-fetch:
|
||||
specifier: 3.3.2
|
||||
version: 3.3.2
|
||||
@@ -469,7 +472,7 @@ importers:
|
||||
version: link:../../packages/config-eslint
|
||||
'@neshca/cache-handler':
|
||||
specifier: 1.9.0
|
||||
version: 1.9.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)
|
||||
version: 1.9.0(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)
|
||||
'@testing-library/jest-dom':
|
||||
specifier: 6.6.3
|
||||
version: 6.6.3
|
||||
@@ -1679,6 +1682,12 @@ packages:
|
||||
'@formkit/auto-animate@0.8.2':
|
||||
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
|
||||
|
||||
'@fortedigital/nextjs-cache-handler@1.2.0':
|
||||
resolution: {integrity: sha512-dHu7+D6yVHI5ii1/DgNSZM9wVPk8uKAB0zrRoNNbZq6hggpRRwAExV4J6bSGOd26RN6ZnfYaGLBmdb0gLpeBQg==}
|
||||
peerDependencies:
|
||||
next: '>=13.5.1'
|
||||
redis: '>=4.6'
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
|
||||
|
||||
@@ -2011,56 +2020,62 @@ packages:
|
||||
next: '>= 13.5.1 < 15'
|
||||
redis: '>= 4.6'
|
||||
|
||||
'@next/env@15.3.1':
|
||||
resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==}
|
||||
'@next/env@14.2.28':
|
||||
resolution: {integrity: sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==}
|
||||
|
||||
'@next/eslint-plugin-next@15.3.1':
|
||||
resolution: {integrity: sha512-oEs4dsfM6iyER3jTzMm4kDSbrQJq8wZw5fmT6fg2V3SMo+kgG+cShzLfEV20senZzv8VF+puNLheiGPlBGsv2A==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.3.1':
|
||||
resolution: {integrity: sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==}
|
||||
'@next/swc-darwin-arm64@14.2.28':
|
||||
resolution: {integrity: sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.3.1':
|
||||
resolution: {integrity: sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==}
|
||||
'@next/swc-darwin-x64@14.2.28':
|
||||
resolution: {integrity: sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.3.1':
|
||||
resolution: {integrity: sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==}
|
||||
'@next/swc-linux-arm64-gnu@14.2.28':
|
||||
resolution: {integrity: sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.3.1':
|
||||
resolution: {integrity: sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==}
|
||||
'@next/swc-linux-arm64-musl@14.2.28':
|
||||
resolution: {integrity: sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.3.1':
|
||||
resolution: {integrity: sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==}
|
||||
'@next/swc-linux-x64-gnu@14.2.28':
|
||||
resolution: {integrity: sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.3.1':
|
||||
resolution: {integrity: sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==}
|
||||
'@next/swc-linux-x64-musl@14.2.28':
|
||||
resolution: {integrity: sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.3.1':
|
||||
resolution: {integrity: sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==}
|
||||
'@next/swc-win32-arm64-msvc@14.2.28':
|
||||
resolution: {integrity: sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.3.1':
|
||||
resolution: {integrity: sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==}
|
||||
'@next/swc-win32-ia32-msvc@14.2.28':
|
||||
resolution: {integrity: sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@14.2.28':
|
||||
resolution: {integrity: sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -3976,8 +3991,8 @@ packages:
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
'@swc/helpers@0.5.5':
|
||||
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
|
||||
|
||||
'@t3-oss/env-core@0.13.4':
|
||||
resolution: {integrity: sha512-zVOiYO0+CF7EnBScz8s0O5JnJLPTU0lrUi8qhKXfIxIJXvI/jcppSiXXsEJwfB4A6XZawY/Wg/EQGKANi/aPmQ==}
|
||||
@@ -7668,24 +7683,21 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
next@15.3.1:
|
||||
resolution: {integrity: sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
next@14.2.28:
|
||||
resolution: {integrity: sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==}
|
||||
engines: {node: '>=18.17.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.41.2
|
||||
babel-plugin-react-compiler: '*'
|
||||
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
sass: ^1.3.0
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
@@ -9163,13 +9175,13 @@ packages:
|
||||
strnum@2.0.5:
|
||||
resolution: {integrity: sha512-YAT3K/sgpCUxhxNMrrdhtod3jckkpYwH6JAuwmUdXZsmzH1wUyzTMrrK2wYCEEqlKwrWDd35NeuUkbBy/1iK+Q==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
styled-jsx@5.1.1:
|
||||
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '*'
|
||||
babel-plugin-macros: '*'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
@@ -11804,6 +11816,16 @@ snapshots:
|
||||
|
||||
'@formkit/auto-animate@0.8.2': {}
|
||||
|
||||
'@fortedigital/nextjs-cache-handler@1.2.0(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)':
|
||||
dependencies:
|
||||
'@neshca/cache-handler': 1.9.0(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)
|
||||
cluster-key-slot: 1.1.2
|
||||
lru-cache: 11.1.0
|
||||
next: 14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
redis: 4.7.0
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.0
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
optional: true
|
||||
|
||||
@@ -12255,41 +12277,44 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.9.0
|
||||
optional: true
|
||||
|
||||
'@neshca/cache-handler@1.9.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)':
|
||||
'@neshca/cache-handler@1.9.0(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)':
|
||||
dependencies:
|
||||
cluster-key-slot: 1.1.2
|
||||
lru-cache: 10.4.3
|
||||
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next: 14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
redis: 4.7.0
|
||||
|
||||
'@next/env@15.3.1': {}
|
||||
'@next/env@14.2.28': {}
|
||||
|
||||
'@next/eslint-plugin-next@15.3.1':
|
||||
dependencies:
|
||||
fast-glob: 3.3.1
|
||||
|
||||
'@next/swc-darwin-arm64@15.3.1':
|
||||
'@next/swc-darwin-arm64@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.3.1':
|
||||
'@next/swc-darwin-x64@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.3.1':
|
||||
'@next/swc-linux-arm64-gnu@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.3.1':
|
||||
'@next/swc-linux-arm64-musl@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.3.1':
|
||||
'@next/swc-linux-x64-gnu@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.3.1':
|
||||
'@next/swc-linux-x64-musl@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.3.1':
|
||||
'@next/swc-win32-arm64-msvc@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.3.1':
|
||||
'@next/swc-win32-ia32-msvc@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@14.2.28':
|
||||
optional: true
|
||||
|
||||
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
|
||||
@@ -13804,7 +13829,7 @@ snapshots:
|
||||
|
||||
'@sentry/core@9.15.0': {}
|
||||
|
||||
'@sentry/nextjs@9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8)':
|
||||
'@sentry/nextjs@9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.32.0
|
||||
@@ -13817,7 +13842,7 @@ snapshots:
|
||||
'@sentry/vercel-edge': 9.15.0
|
||||
'@sentry/webpack-plugin': 3.3.1(encoding@0.1.13)(webpack@5.99.8)
|
||||
chalk: 3.0.0
|
||||
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next: 14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
resolve: 1.22.8
|
||||
rollup: 4.35.0
|
||||
stacktrace-parser: 0.1.11
|
||||
@@ -14493,8 +14518,9 @@ snapshots:
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
'@swc/helpers@0.5.5':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@t3-oss/env-core@0.13.4(arktype@2.1.20)(typescript@5.8.3)(zod@3.24.4)':
|
||||
@@ -18594,13 +18620,13 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next-auth@4.24.11(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
next-auth@4.24.11(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.0
|
||||
'@panva/hkdf': 1.2.1
|
||||
cookie: 0.7.2
|
||||
jose: 4.15.9
|
||||
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next: 14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
oauth: 0.9.15
|
||||
openid-client: 5.7.1
|
||||
preact: 10.26.5
|
||||
@@ -18611,37 +18637,37 @@ snapshots:
|
||||
optionalDependencies:
|
||||
nodemailer: 7.0.2
|
||||
|
||||
next-safe-action@7.10.8(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.24.4):
|
||||
next-safe-action@7.10.8(next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.24.4):
|
||||
dependencies:
|
||||
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next: 14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
zod: 3.24.4
|
||||
|
||||
next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
next@14.2.28(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.3.1
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/helpers': 0.5.15
|
||||
'@next/env': 14.2.28
|
||||
'@swc/helpers': 0.5.5
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001715
|
||||
graceful-fs: 4.2.11
|
||||
postcss: 8.4.31
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
styled-jsx: 5.1.6(react@19.1.0)
|
||||
styled-jsx: 5.1.1(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.3.1
|
||||
'@next/swc-darwin-x64': 15.3.1
|
||||
'@next/swc-linux-arm64-gnu': 15.3.1
|
||||
'@next/swc-linux-arm64-musl': 15.3.1
|
||||
'@next/swc-linux-x64-gnu': 15.3.1
|
||||
'@next/swc-linux-x64-musl': 15.3.1
|
||||
'@next/swc-win32-arm64-msvc': 15.3.1
|
||||
'@next/swc-win32-x64-msvc': 15.3.1
|
||||
'@next/swc-darwin-arm64': 14.2.28
|
||||
'@next/swc-darwin-x64': 14.2.28
|
||||
'@next/swc-linux-arm64-gnu': 14.2.28
|
||||
'@next/swc-linux-arm64-musl': 14.2.28
|
||||
'@next/swc-linux-x64-gnu': 14.2.28
|
||||
'@next/swc-linux-x64-musl': 14.2.28
|
||||
'@next/swc-win32-arm64-msvc': 14.2.28
|
||||
'@next/swc-win32-ia32-msvc': 14.2.28
|
||||
'@next/swc-win32-x64-msvc': 14.2.28
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@playwright/test': 1.52.0
|
||||
sharp: 0.34.1
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
@@ -20270,7 +20296,7 @@ snapshots:
|
||||
|
||||
strnum@2.0.5: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.1.0):
|
||||
styled-jsx@5.1.1(react@19.1.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.1.0
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
"BREVO_LIST_ID",
|
||||
"DOCKER_CRON_ENABLED",
|
||||
"CRON_SECRET",
|
||||
"CUSTOM_CACHE_DISABLED",
|
||||
"DATABASE_URL",
|
||||
"DEBUG",
|
||||
"E2E_TESTING",
|
||||
@@ -133,7 +132,6 @@
|
||||
"RATE_LIMITING_DISABLED",
|
||||
"REDIS_HTTP_URL",
|
||||
"REDIS_URL",
|
||||
"REDIS_DEFAULT_TTL",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_BUCKET_NAME",
|
||||
"S3_ENDPOINT_URL",
|
||||
|
||||
Reference in New Issue
Block a user