Compare commits

..

9 Commits

Author SHA1 Message Date
Matthias Nannt
13f9f47bbf Merge branch 'main' of github.com:formbricks/formbricks into chore/next-14-downgrade 2025-05-10 12:20:13 +02:00
Matthias Nannt
f95e039e89 remove cacheMaxMemorySize 2025-05-10 12:20:07 +02:00
Anshuman Pandey
fcbb97010c fix: follow ups ending card (#5732) 2025-05-10 10:30:49 +02:00
Matthias Nannt
9481f2087b use old cache 2025-05-10 10:23:28 +02:00
Matthias Nannt
f954bbb30d fix dev server 2025-05-09 21:12:01 +02:00
Matthias Nannt
515b5fc311 fix issues with older nextjs version 2025-05-09 20:19:43 +02:00
Matthias Nannt
77f0e344c3 chore: improve caching by next 14 downgrade 2025-05-09 20:06:17 +02:00
Matti Nannt
6be46b16b2 fix: limit number of surveys in environment state (#5715) 2025-05-09 16:10:01 +00:00
Matti Nannt
35b2356a31 fix: nextjs cache handler for next 15 (#5717)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-09 17:51:57 +02:00
35 changed files with 1066 additions and 630 deletions

View File

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

View File

@@ -1,235 +0,0 @@
import {
mockContactEmailFollowUp,
mockDirectEmailFollowUp,
mockEndingFollowUp,
mockEndingId2,
mockResponse,
mockResponseEmailFollowUp,
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Follow Up", () => {
const mockOrganization: Partial<TOrganization> = {
id: "org1",
name: "Test Org",
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
};
describe("evaluateFollowUp", () => {
test("sends email when to is a direct email address", async () => {
const followUpId = mockDirectEmailFollowUp.id;
const followUpAction = mockDirectEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
mockResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockDirectEmailFollowUp.action.properties.body,
subject: mockDirectEmailFollowUp.action.properties.subject,
to: mockDirectEmailFollowUp.action.properties.to,
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockResponseEmailFollowUp.action.properties.body,
subject: mockResponseEmailFollowUp.action.properties.subject,
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email in array", async () => {
const followUpId = mockContactEmailFollowUp.id;
const followUpAction = mockContactEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurveyWithContactQuestion,
mockResponseWithContactQuestion,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockContactEmailFollowUp.action.properties.body,
subject: mockContactEmailFollowUp.action.properties.subject,
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
survey: mockSurveyWithContactQuestion,
response: mockResponseWithContactQuestion,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("throws error when to value is not found in response data", async () => {
const followUpId = "followup1";
const followUpAction = {
...mockSurvey.followUps![0].action,
properties: {
...mockSurvey.followUps![0].action.properties,
to: "nonExistentField",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
});
test("throws error when email address is invalid", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
const invalidResponse = {
...mockResponse,
data: {
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
invalidResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
});
});
describe("sendSurveyFollowUps", () => {
test("skips follow-up when ending Id doesn't match", async () => {
const responseWithDifferentEnding = {
...mockResponse,
endingId: mockEndingId2,
};
const mockSurveyWithEndingFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockEndingFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithEndingFollowUp,
responseWithDifferentEnding as TResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockEndingFollowUp.id,
status: "skipped",
},
]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
});
test("processes follow-ups and log errors", async () => {
const error = new Error("Test error");
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
const mockSurveyWithFollowUps: TSurvey = {
...mockSurvey,
followUps: [mockResponseEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUps,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockResponseEmailFollowUp.id,
status: "error",
error: "Test error",
},
]);
expect(logger.error).toHaveBeenCalledWith(
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
"Follow-up processing errors"
);
});
test("successfully processes follow-ups", async () => {
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
const mockSurveyWithFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockDirectEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUp,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockDirectEmailFollowUp.id,
status: "success",
},
]);
expect(logger.error).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,135 +0,0 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
export const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<void> => {
const { properties } = followUpAction;
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
html: body,
subject,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return;
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
}
};
export const sendSurveyFollowUps = async (
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return Promise.resolve({
followUpId: followUp.id,
status: "skipped",
});
}
}
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,
}))
.catch((error) => ({
followUpId: followUp.id,
status: "error" as const,
error: error instanceof Error ? error.message : "Something went wrong",
}));
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
logger.error(errors, "Follow-up processing errors");
}
return followUpResults;
};

View File

@@ -1,4 +1,3 @@
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
select: { email: true, locale: true },
});
// send follow up emails
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
if (surveyFollowUpsPermission) {
await sendSurveyFollowUps(survey, response, organization);
if (survey.followUps?.length > 0) {
// send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (!followUpsResult.ok) {
const { error: followUpsError } = followUpsResult;
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
}
}
}
const emailPromises = usersWithNotifications.map((user) =>

View File

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

View File

@@ -21,6 +21,10 @@ export const getSurveysForEnvironmentState = reactCache(
where: {
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 30,
select: {
id: true,
welcomeCard: true,

View File

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

View File

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

View File

@@ -1,70 +1,99 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { loginLimiter, signupLimiter } from "./bucket";
import type { Mock } from "vitest";
// Mock constants
vi.mock("@/lib/constants", () => ({
ENTERPRISE_LICENSE_KEY: undefined,
REDIS_HTTP_URL: undefined,
LOGIN_RATE_LIMIT: {
interval: 15 * 60,
allowedPerInterval: 5,
},
SIGNUP_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
VERIFY_EMAIL_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
FORGET_PASSWORD_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
CLIENT_SIDE_API_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
SHARE_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
SYNC_USER_IDENTIFICATION_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
}));
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
describe("Rate Limiters", () => {
describe("bucket middleware rate limiters", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
});
test("loginLimiter allows requests within limit", () => {
const token = "test-token-1";
// Should not throw for first request
expect(() => loginLimiter(token)).not.toThrow();
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const { loginLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
expect(loginLimiter).toEqual({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
});
test("loginLimiter throws when limit exceeded", () => {
const token = "test-token-2";
// Make multiple requests to exceed the limit
for (let i = 0; i < 5; i++) {
expect(() => loginLimiter(token)).not.toThrow();
}
// Next request should throw
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const { signupLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
expect(signupLimiter).toEqual({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
});
test("different limiters use different counters", () => {
const token = "test-token-3";
// Exceed login limit
for (let i = 0; i < 5; i++) {
expect(() => loginLimiter(token)).not.toThrow();
}
// Should throw for login
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
// Should still be able to use signup limiter
expect(() => signupLimiter(token)).not.toThrow();
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const { verifyEmailLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
expect(verifyEmailLimiter).toEqual({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
});
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
const { forgotPasswordLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
expect(forgotPasswordLimiter).toEqual({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
expect(clientSideApiEndpointsLimiter).toEqual({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
});
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
const { shareUrlLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
expect(shareUrlLimiter).toEqual({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
expect(syncUserIdentificationLimiter).toEqual({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
});
});

View File

@@ -1,4 +1,3 @@
import { rateLimit } from "@/app/middleware/rate-limit";
import {
CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT,
@@ -8,6 +7,7 @@ import {
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
describe("in-memory rate limiter", () => {
test("allows requests within limit and throws after limit", async () => {
const { rateLimit } = await import("./rate-limit");
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
await expect(limiterFn("a")).resolves.toBeUndefined();
await expect(limiterFn("a")).resolves.toBeUndefined();
await expect(limiterFn("a")).rejects.toThrow("Rate limit exceeded");
});
test("separate tokens have separate counts", async () => {
const { rateLimit } = await import("./rate-limit");
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
await expect(limiterFn("x")).resolves.toBeUndefined();
await expect(limiterFn("y")).resolves.toBeUndefined();
await expect(limiterFn("x")).resolves.toBeUndefined();
await expect(limiterFn("y")).resolves.toBeUndefined();
});
});
describe("redis rate limiter", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
REDIS_HTTP_URL: "http://redis",
}));
});
test("sets expire on first use and does not throw", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 1 }) })
.mockResolvedValueOnce({ ok: true });
const { rateLimit } = await import("./rate-limit");
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
await expect(limiter("t")).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith("http://redis/INCR/t");
expect(fetch).toHaveBeenCalledWith("http://redis/EXPIRE/t/10");
});
test("does not throw when redis INCR response not ok", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false });
const { rateLimit } = await import("./rate-limit");
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
await expect(limiter("t")).resolves.toBeUndefined();
});
test("throws when INCR exceeds limit", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 3 }) });
const { rateLimit } = await import("./rate-limit");
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
await expect(limiter("t")).rejects.toThrow("Rate limit exceeded for IP: t");
});
});

View File

@@ -1,4 +1,4 @@
import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@/lib/constants";
import { REDIS_HTTP_URL } from "@/lib/constants";
import { LRUCache } from "lru-cache";
import { logger } from "@formbricks/logger";
@@ -13,7 +13,7 @@ const inMemoryRateLimiter = (options: Options) => {
ttl: options.interval * 1000, // converts to expected input of milliseconds
});
return (token: string) => {
return async (token: string) => {
const currentUsage = tokenCache.get(token) ?? 0;
if (currentUsage >= options.allowedPerInterval) {
throw new Error("Rate limit exceeded");
@@ -40,12 +40,13 @@ const redisRateLimiter = (options: Options) => async (token: string) => {
throw new Error();
}
} catch (e) {
logger.error({ error: e }, "Rate limit exceeded");
throw new Error("Rate limit exceeded for IP: " + token);
}
};
export const rateLimit = (options: Options) => {
if (REDIS_HTTP_URL && ENTERPRISE_LICENSE_KEY) {
if (REDIS_HTTP_URL) {
return redisRateLimiter(options);
} else {
return inMemoryRateLimiter(options);

View File

@@ -67,24 +67,24 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return null;
};
const applyRateLimiting = (request: NextRequest, ip: string) => {
const applyRateLimiting = async (request: NextRequest, ip: string) => {
if (isLoginRoute(request.nextUrl.pathname)) {
loginLimiter(`login-${ip}`);
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
signupLimiter(`signup-${ip}`);
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
verifyEmailLimiter(`verify-email-${ip}`);
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
forgotPasswordLimiter(`forgot-password-${ip}`);
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
shareUrlLimiter(`share-${ip}`);
await shareUrlLimiter(`share-${ip}`);
}
};
@@ -153,7 +153,7 @@ export const middleware = async (originalRequest: NextRequest) => {
if (ip) {
try {
applyRateLimiting(request, ip);
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
const apiError: ApiErrorResponseV2 = {

View File

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

View File

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

View File

@@ -33,7 +33,6 @@ import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { OnboardingInviteEmail } from "./emails/invite/onboarding-invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { FollowUpEmail } from "./emails/survey/follow-up";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
import { NoLiveSurveyNotificationEmail } from "./emails/weekly-summary/no-live-survey-notification-email";
@@ -355,40 +354,3 @@ export const sendNoLiveSurveyNotificationEmail = async (
html,
});
};
export const sendFollowUpEmail = async ({
html,
replyTo,
subject,
to,
survey,
response,
attachResponseData = false,
logoUrl,
}: {
html: string;
subject: string;
to: string;
replyTo: string[];
attachResponseData: boolean;
survey: TSurvey;
response: TResponse;
logoUrl?: string;
}): Promise<void> => {
const emailHtmlBody = await render(
await FollowUpEmail({
html,
logoUrl,
attachResponseData,
survey,
response,
})
);
await sendEmail({
to,
replyTo: replyTo.join(", "),
subject,
html: emailHtmlBody,
});
};

View File

@@ -5,7 +5,7 @@ import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { FollowUpEmail } from "./follow-up";
import { FollowUpEmail } from "./follow-up-email";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -24,7 +24,28 @@ vi.mock("@/modules/email/emails/lib/utils", () => ({
}));
const defaultProps = {
html: "<p>Test HTML Content</p>",
followUp: {
id: "followupid",
createdAt: new Date(),
updatedAt: new Date(),
name: "Follow Up Email",
trigger: {
type: "response" as const,
properties: null,
},
action: {
type: "send-email" as const,
properties: {
to: "",
from: "test@test.com",
replyTo: ["test@test.com"],
subject: "Subject",
body: "<p>Test HTML Content</p>",
attachResponseData: true,
},
},
surveyId: "surveyid",
},
logoUrl: "https://example.com/custom-logo.png",
attachResponseData: false,
survey: {

View File

@@ -17,6 +17,7 @@ import {
} from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -24,23 +25,20 @@ const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface FollowUpEmailProps {
html: string;
logoUrl?: string;
attachResponseData: boolean;
survey: TSurvey;
response: TResponse;
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
export async function FollowUpEmail({
html,
logoUrl,
attachResponseData,
survey,
response,
}: FollowUpEmailProps): Promise<React.JSX.Element> {
const questions = attachResponseData ? getQuestionResponseMapping(survey, response) : [];
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
const { properties } = props.followUp.action;
const { body } = properties;
const questions = props.attachResponseData ? getQuestionResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
const isDefaultLogo = !props.logoUrl || props.logoUrl === fbLogoUrl;
return (
<Html>
@@ -56,13 +54,13 @@ export async function FollowUpEmail({
<Img alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={logoUrl} />
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={props.logoUrl} />
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left text-sm">
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(html, {
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https

View File

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

View File

@@ -0,0 +1,370 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { sendFollowUpEmail } from "./email";
import { sendFollowUpsForResponse } from "./follow-ups";
import { getSurveyFollowUpsPermission } from "./utils";
// Mock all dependencies
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
getResponse: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("./email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("./utils", () => ({
getSurveyFollowUpsPermission: vi.fn(),
}));
describe("Follow-ups", () => {
const mockResponse = {
id: "response1",
surveyId: "survey1",
data: {
email: "test@example.com",
question1: "answer1",
},
endingId: "ending1",
} as unknown as TResponse;
const mockSurvey = {
id: "survey1",
environmentId: "env1",
followUps: [
{
id: "followup1",
action: {
type: "email",
properties: {
to: "email",
replyTo: "noreply@example.com",
attachResponseData: true,
},
},
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
},
],
} as unknown as TSurvey;
const mockOrganization = {
id: "org1",
billing: {
plan: "scale",
limits: {
monthly: { miu: 1000, responses: 1000 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
stripeCustomerId: "cus123",
},
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
} as unknown as TOrganization;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getResponse).mockResolvedValue(mockResponse);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
vi.mocked(sendFollowUpEmail).mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("sendFollowUpsForResponse", () => {
test("should successfully send follow-up emails", async () => {
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "success",
});
expect(sendFollowUpEmail).toHaveBeenCalledWith({
followUp: mockSurvey.followUps[0],
to: "test@example.com",
replyTo: "noreply@example.com",
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
}
});
test("should return error when response is not found", async () => {
vi.mocked(getResponse).mockResolvedValue(null);
const result = await sendFollowUpsForResponse("nonexistentresponse");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.RESPONSE_NOT_FOUND,
message: "Response not found",
meta: { responseId: "nonexistentresponse" },
});
}
});
test("should return error when survey is not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.SURVEY_NOT_FOUND,
message: "Survey not found",
meta: { responseId: "response1", surveyId: "survey1" },
});
}
});
test("should return error when organization is not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.ORG_NOT_FOUND,
message: "Organization not found",
meta: { responseId: "response1", surveyId: "survey1", environmentId: "env1" },
});
}
});
test("should return error when follow-ups are not allowed", async () => {
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.FOLLOW_UP_NOT_ALLOWED,
message: "Survey follow-ups are not allowed for this organization",
meta: { responseId: "response1", surveyId: "survey1", organizationId: "org1" },
});
}
});
test("should skip follow-up when ending ID doesn't match", async () => {
const modifiedResponse = {
...mockResponse,
endingId: "different-ending",
};
vi.mocked(getResponse).mockResolvedValue(modifiedResponse);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "skipped",
});
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
test("should handle direct email address in follow-up", async () => {
const modifiedSurvey = {
...mockSurvey,
followUps: [
{
...mockSurvey.followUps[0],
action: {
...mockSurvey.followUps[0].action,
properties: {
...mockSurvey.followUps[0].action.properties,
to: "direct@example.com",
},
},
},
],
};
vi.mocked(getSurvey).mockResolvedValue(modifiedSurvey);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "success",
});
expect(sendFollowUpEmail).toHaveBeenCalledWith({
followUp: modifiedSurvey.followUps[0],
to: "direct@example.com",
replyTo: "noreply@example.com",
survey: modifiedSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
}
});
test("should handle invalid email address in response data", async () => {
const modifiedResponse = {
...mockResponse,
data: {
email: "invalid-email",
},
};
vi.mocked(getResponse).mockResolvedValue(modifiedResponse);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "error",
error: "Email address is not valid for followup: followup1",
});
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
test("should handle missing email value in response data", async () => {
const modifiedResponse = {
...mockResponse,
data: {},
};
vi.mocked(getSurvey).mockResolvedValue({
...mockSurvey,
followUps: [
{
id: "followup1",
action: {
type: "email",
properties: {
to: "email",
replyTo: "noreply@example.com",
attachResponseData: true,
},
},
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
},
],
} as unknown as TSurvey);
vi.mocked(getResponse).mockResolvedValue(modifiedResponse as unknown as any);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "error",
error: "To value not found in response data for followup: followup1",
});
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
test("should handle email sending error", async () => {
vi.mocked(getSurvey).mockResolvedValue({
...mockSurvey,
followUps: [
{
id: "followup1",
action: {
type: "email",
properties: {
to: "hello@example.com",
replyTo: "noreply@example.com",
attachResponseData: true,
},
},
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
},
],
} as unknown as TSurvey);
vi.mocked(sendFollowUpEmail).mockRejectedValue(new Error("Failed to send email"));
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "error",
error: "Failed to send email",
});
}
});
test("should return empty array when no follow-ups are configured", async () => {
const modifiedSurvey = {
...mockSurvey,
followUps: [],
} as unknown as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(modifiedSurvey);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual([]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
});
});

View File

@@ -0,0 +1,276 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { rateLimit } from "@/lib/utils/rate-limit";
import { validateInputs } from "@/lib/utils/validate";
import { sendFollowUpEmail } from "@/modules/survey/follow-ups/lib/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { FollowUpResult, FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { z } from "zod";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { Result, err } from "@formbricks/types/error-handlers";
import { ValidationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 50, // max 50 calls per org per hour
});
const evaluateFollowUp = async (
followUp: TSurveyFollowUp,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult> => {
try {
const { properties } = followUp.action;
const { to, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl ?? "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
followUp,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return {
followUpId: followUp.id,
status: "success" as const,
};
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
return {
followUpId: followUp.id,
status: "error",
error: `To value not found in response data for followup: ${followUp.id}`,
};
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.success) {
// send email to this email address
await sendFollowUpEmail({
followUp,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
return {
followUpId: followUp.id,
status: "success" as const,
};
}
return {
followUpId: followUp.id,
status: "error",
error: `Email address is not valid for followup: ${followUp.id}`,
};
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
return {
followUpId: followUp.id,
status: "error",
error: `Email address not found in response data for followup: ${followUp.id}`,
};
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail({
followUp,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
return {
followUpId: followUp.id,
status: "success" as const,
};
}
return {
followUpId: followUp.id,
status: "error",
error: `Email address is not valid for followup: ${followUp.id}`,
};
}
return {
followUpId: followUp.id,
status: "error",
error: "Something went wrong",
};
} catch (error) {
return {
followUpId: followUp.id,
status: "error",
error: error instanceof Error ? error.message : "Something went wrong",
};
}
};
/**
* Sends follow-up emails for a survey response.
* This is the main entry point for sending follow-ups - it handles all the logic internally
* and only requires a response ID.
*/
export const sendFollowUpsForResponse = async (
responseId: string
): Promise<Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: any }>> => {
try {
validateInputs([responseId, ZId]);
// Get the response first to get the survey ID
const response = await getResponse(responseId);
if (!response) {
return err({
code: FollowUpSendError.RESPONSE_NOT_FOUND,
message: "Response not found",
meta: { responseId },
});
}
const surveyId = response.surveyId;
const survey = await getSurvey(surveyId);
if (!survey) {
return err({
code: FollowUpSendError.SURVEY_NOT_FOUND,
message: "Survey not found",
meta: { responseId, surveyId },
});
}
// Get organization from survey's environmentId
const organization = await getOrganizationByEnvironmentId(survey.environmentId);
if (!organization) {
return err({
code: FollowUpSendError.ORG_NOT_FOUND,
message: "Organization not found",
meta: { responseId, surveyId, environmentId: survey.environmentId },
});
}
// Check if follow-ups are allowed for this organization
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!surveyFollowUpsPermission) {
return err({
code: FollowUpSendError.FOLLOW_UP_NOT_ALLOWED,
message: "Survey follow-ups are not allowed for this organization",
meta: { responseId, surveyId, organizationId: organization.id },
});
}
// Check rate limit
try {
await limiter(organization.id);
} catch {
return err({
code: FollowUpSendError.RATE_LIMIT_EXCEEDED,
message: "Too many followup requests; please wait a bit and try again.",
});
}
// If no follow-ups configured, return empty array
if (!survey.followUps?.length) {
return {
ok: true,
data: [],
};
}
// Process each follow-up
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return {
followUpId: followUp.id,
status: "skipped",
};
}
}
return evaluateFollowUp(followUp, survey, response, organization);
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
logger.error(
{
errors,
meta: {
responseId,
surveyId,
organizationId: organization.id,
},
},
"Follow-up processing errors"
);
}
return {
ok: true,
data: followUpResults,
};
} catch (error) {
logger.error(
{
error,
meta: { responseId },
},
"Unexpected error while sending follow-ups"
);
if (error instanceof ValidationError) {
return err({
code: FollowUpSendError.VALIDATION_ERROR,
message: error.message,
meta: { responseId },
});
}
return err({
code: FollowUpSendError.UNEXPECTED_ERROR,
message: "An unexpected error occurred while sending follow-ups",
meta: { responseId },
});
}
};

View File

@@ -0,0 +1,16 @@
export type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
export enum FollowUpSendError {
VALIDATION_ERROR = "validation_error",
ORG_NOT_FOUND = "organization_not_found",
SURVEY_NOT_FOUND = "survey_not_found",
RESPONSE_NOT_FOUND = "response_not_found",
RESPONSE_SURVEY_MISMATCH = "response_survey_mismatch",
FOLLOW_UP_NOT_ALLOWED = "follow_up_not_allowed",
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded",
UNEXPECTED_ERROR = "unexpected_error",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

166
pnpm-lock.yaml generated
View File

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

View File

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