Compare commits

..

7 Commits

Author SHA1 Message Date
Matti Nannt 122de0121e fix: address vendored minimatch review comments (#7608) 2026-03-30 13:52:02 +02:00
Matti Nannt eb416d7ff7 fix: resolve dependabot vulnerability alerts 2026-03-27 09:18:16 +01:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
Dhruwang Jariwala 83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
Dhruwang Jariwala 59cc9c564e fix: duplicate org creation (#7593) 2026-03-26 05:52:09 +00:00
78 changed files with 20149 additions and 11785 deletions
+5
View File
@@ -185,6 +185,11 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
-3
View File
@@ -66,6 +66,3 @@ i18n.cache
stats.html
# next-agents-md
.next-docs/
# Golang
.cache
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+67 -68
View File
@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
const authMethod = getAuthMethod(account);
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
newObject: {
...user,
authMethod: getAuthMethod(account),
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
},
});
},
},
};
return NextAuth(authOptions)(req, ctx);
@@ -3,40 +3,33 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteSurveyLifecycleJobs } from "@/lib/river/survey-lifecycle";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.$transaction(async (tx) => {
await deleteSurveyLifecycleJobs({ tx, surveyId });
const removedSurvey = await tx.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
if (removedSurvey.type === "app" && removedSurvey.segment?.isPrivate) {
await tx.segment.delete({
where: {
id: removedSurvey.segment.id,
},
});
}
return removedSurvey;
});
}
return deletedSurvey;
} catch (error) {
+97
View File
@@ -0,0 +1,97 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
+33
View File
@@ -20,3 +20,36 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
+1
View File
@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
+2
View File
@@ -15,6 +15,7 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -141,6 +142,7 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
-10
View File
@@ -1,10 +0,0 @@
import "server-only";
export const RIVER_SCHEMA = "river";
export const RIVER_SURVEY_LIFECYCLE_QUEUE = "survey_lifecycle";
export const RIVER_SURVEY_START_KIND = "survey.start";
export const RIVER_SURVEY_END_KIND = "survey.end";
export const RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS = 3;
export const RIVER_INSERT_NOTIFICATION_CHANNEL = "river_insert";
export const RIVER_PENDING_JOB_STATES = ["available", "scheduled", "retryable"] as const;
-502
View File
@@ -1,502 +0,0 @@
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { randomUUID } from "crypto";
import { Socket } from "net";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import {
RIVER_INSERT_NOTIFICATION_CHANNEL,
RIVER_PENDING_JOB_STATES,
RIVER_SURVEY_END_KIND,
RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS,
RIVER_SURVEY_LIFECYCLE_QUEUE,
RIVER_SURVEY_START_KIND,
} from "./constants";
import { deleteSurveyLifecycleJobs, enqueueSurveyLifecycleJobs } from "./survey-lifecycle";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const createMockTx = () =>
({
$executeRaw: vi.fn(),
}) as unknown as Prisma.TransactionClient;
const getQueryValues = (callIndex: number, tx: Prisma.TransactionClient) => {
const query = vi.mocked(tx.$executeRaw).mock.calls[callIndex][0] as Prisma.Sql;
return query.values;
};
describe("enqueueSurveyLifecycleJobs", () => {
beforeEach(() => {
vi.mocked(logger.error).mockReset();
});
test("enqueues a start job when startsAt is set on create", async () => {
const tx = createMockTx();
const startsAt = new Date("2026-04-01T12:00:00.000Z");
await enqueueSurveyLifecycleJobs({
tx,
now: new Date("2026-03-31T12:00:00.000Z"),
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt: null,
},
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(2);
expect(getQueryValues(0, tx)).toEqual([
JSON.stringify({
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: startsAt.toISOString(),
}),
RIVER_SURVEY_START_KIND,
RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS,
RIVER_SURVEY_LIFECYCLE_QUEUE,
startsAt,
]);
expect(getQueryValues(1, tx)).toEqual([
`river.${RIVER_INSERT_NOTIFICATION_CHANNEL}`,
JSON.stringify({ queue: RIVER_SURVEY_LIFECYCLE_QUEUE }),
]);
});
test("enqueues an end job when endsAt is set on create", async () => {
const tx = createMockTx();
const endsAt = new Date("2026-04-02T12:00:00.000Z");
await enqueueSurveyLifecycleJobs({
tx,
now: new Date("2026-03-31T12:00:00.000Z"),
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: null,
endsAt,
},
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(2);
expect(getQueryValues(0, tx)).toEqual([
JSON.stringify({
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: endsAt.toISOString(),
}),
RIVER_SURVEY_END_KIND,
RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS,
RIVER_SURVEY_LIFECYCLE_QUEUE,
endsAt,
]);
});
test("enqueues both lifecycle jobs when both dates are set on create", async () => {
const tx = createMockTx();
const startsAt = new Date("2026-04-01T12:00:00.000Z");
const endsAt = new Date("2026-04-02T12:00:00.000Z");
await enqueueSurveyLifecycleJobs({
tx,
now: new Date("2026-03-31T12:00:00.000Z"),
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt,
},
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(3);
expect(getQueryValues(0, tx)[1]).toBe(RIVER_SURVEY_START_KIND);
expect(getQueryValues(1, tx)[1]).toBe(RIVER_SURVEY_END_KIND);
});
test("does nothing when neither lifecycle date is set", async () => {
const tx = createMockTx();
await enqueueSurveyLifecycleJobs({
tx,
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: null,
endsAt: null,
},
});
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
test("enqueues a lifecycle job when a date transitions from null to a value", async () => {
const tx = createMockTx();
const startsAt = new Date("2026-04-01T12:00:00.000Z");
await enqueueSurveyLifecycleJobs({
tx,
now: new Date("2026-03-31T12:00:00.000Z"),
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt: null,
},
previousSurvey: {
startsAt: null,
endsAt: null,
},
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(2);
});
test("does not enqueue when a lifecycle date changes after already being set", async () => {
const tx = createMockTx();
await enqueueSurveyLifecycleJobs({
tx,
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: new Date("2026-04-02T12:00:00.000Z"),
endsAt: null,
},
previousSurvey: {
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
},
});
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
test("does not enqueue when a lifecycle date is cleared", async () => {
const tx = createMockTx();
await enqueueSurveyLifecycleJobs({
tx,
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: null,
endsAt: null,
},
previousSurvey: {
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
},
});
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
test("logs and rethrows SQL errors", async () => {
const tx = createMockTx();
const queryError = new Error("insert failed");
vi.mocked(tx.$executeRaw).mockRejectedValueOnce(queryError);
await expect(
enqueueSurveyLifecycleJobs({
tx,
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
},
})
).rejects.toThrow(queryError);
expect(logger.error).toHaveBeenCalledWith(
{ error: queryError, surveyId: "survey_1" },
"Failed to enqueue survey lifecycle jobs"
);
});
});
describe("deleteSurveyLifecycleJobs", () => {
beforeEach(() => {
vi.mocked(logger.error).mockReset();
});
test("deletes pending lifecycle jobs for the survey", async () => {
const tx = createMockTx();
await deleteSurveyLifecycleJobs({
tx,
surveyId: "survey_1",
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
expect(getQueryValues(0, tx)).toEqual([
RIVER_SURVEY_START_KIND,
RIVER_SURVEY_END_KIND,
"survey_1",
...RIVER_PENDING_JOB_STATES,
]);
});
test("logs and rethrows delete failures", async () => {
const tx = createMockTx();
const queryError = new Error("delete failed");
vi.mocked(tx.$executeRaw).mockRejectedValueOnce(queryError);
await expect(
deleteSurveyLifecycleJobs({
tx,
surveyId: "survey_1",
})
).rejects.toThrow(queryError);
expect(logger.error).toHaveBeenCalledWith(
{ error: queryError, surveyId: "survey_1" },
"Failed to delete pending survey lifecycle jobs"
);
});
});
const canReachPostgres = async (databaseURL?: string): Promise<boolean> => {
if (!databaseURL) {
return false;
}
try {
const parsedURL = new URL(databaseURL);
const port = Number(parsedURL.port || "5432");
await new Promise<void>((resolve, reject) => {
const socket = new Socket();
socket.setTimeout(500);
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("timeout"));
});
socket.once("error", (error) => {
socket.destroy();
reject(error);
});
socket.connect(port, parsedURL.hostname);
});
return true;
} catch {
return false;
}
};
const describeIfDatabase = (await canReachPostgres(process.env.DATABASE_URL)) ? describe : describe.skip;
describeIfDatabase("survey lifecycle integration", () => {
let integrationPrisma: PrismaClient;
let schema: string;
const quoteIdentifier = (identifier: string) => `"${identifier}"`;
beforeAll(() => {
return vi
.importActual<typeof import("@prisma/client")>("@prisma/client")
.then(({ PrismaClient: ActualPrismaClient }) => {
integrationPrisma = new ActualPrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
});
});
beforeEach(async () => {
schema = `river_test_${randomUUID().replace(/-/g, "_")}`;
await integrationPrisma.$executeRaw(Prisma.raw(`CREATE SCHEMA ${quoteIdentifier(schema)}`));
await integrationPrisma.$executeRaw(
Prisma.raw(`
CREATE TYPE ${quoteIdentifier(schema)}.${quoteIdentifier("river_job_state")} AS ENUM (
'available',
'scheduled',
'retryable',
'completed'
)
`)
);
await integrationPrisma.$executeRaw(
Prisma.raw(`
CREATE TABLE ${quoteIdentifier(schema)}.${quoteIdentifier("river_job")} (
id BIGSERIAL PRIMARY KEY,
state ${quoteIdentifier(schema)}.${quoteIdentifier("river_job_state")} NOT NULL DEFAULT 'available',
args JSONB NOT NULL,
kind TEXT NOT NULL,
max_attempts SMALLINT NOT NULL,
queue TEXT NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
);
});
afterEach(async () => {
await integrationPrisma.$executeRaw(
Prisma.raw(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schema)} CASCADE`)
);
});
afterAll(async () => {
await integrationPrisma.$disconnect();
});
test("persists scheduled and immediate lifecycle jobs with the expected payload", async () => {
const surveyId = "survey_1";
const environmentId = "env_1";
const startsAt = new Date("2026-05-01T12:00:00.000Z");
const endsAt = new Date("2026-03-01T12:00:00.000Z");
const beforeInsert = new Date();
await integrationPrisma.$transaction(async (tx) => {
await enqueueSurveyLifecycleJobs({
tx,
schema,
now: new Date("2026-04-01T12:00:00.000Z"),
survey: {
id: surveyId,
environmentId,
startsAt,
endsAt: null,
},
});
await enqueueSurveyLifecycleJobs({
tx,
schema,
now: new Date("2026-04-01T12:00:00.000Z"),
survey: {
id: surveyId,
environmentId,
startsAt,
endsAt,
},
previousSurvey: {
startsAt,
endsAt: null,
},
});
});
const afterInsert = new Date();
const jobs = await integrationPrisma.$queryRaw<
Array<{
kind: string;
queue: string;
args: Record<string, string>;
scheduled_at: Date;
max_attempts: number;
}>
>(
Prisma.raw(
`SELECT kind, queue, args, scheduled_at, max_attempts
FROM ${quoteIdentifier(schema)}.${quoteIdentifier("river_job")}
ORDER BY kind ASC`
)
);
expect(jobs).toHaveLength(2);
expect(jobs).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: RIVER_SURVEY_END_KIND,
queue: RIVER_SURVEY_LIFECYCLE_QUEUE,
args: {
surveyId,
environmentId,
scheduledFor: endsAt.toISOString(),
},
max_attempts: RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS,
}),
expect.objectContaining({
kind: RIVER_SURVEY_START_KIND,
queue: RIVER_SURVEY_LIFECYCLE_QUEUE,
args: {
surveyId,
environmentId,
scheduledFor: startsAt.toISOString(),
},
scheduled_at: startsAt,
max_attempts: RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS,
}),
])
);
const immediateJob = jobs.find((job) => job.kind === RIVER_SURVEY_END_KIND);
expect(immediateJob?.scheduled_at.getTime()).toBeGreaterThanOrEqual(beforeInsert.getTime() - 1000);
expect(immediateJob?.scheduled_at.getTime()).toBeLessThanOrEqual(afterInsert.getTime() + 1000);
});
test("removes only pending lifecycle jobs for the target survey", async () => {
const surveyId = "survey_1";
await integrationPrisma.$executeRaw(
Prisma.raw(`
INSERT INTO ${quoteIdentifier(schema)}.${quoteIdentifier("river_job")}
(state, args, kind, max_attempts, queue)
VALUES
('available', '{"surveyId":"survey_1","environmentId":"env_1","scheduledFor":"2026-04-01T12:00:00.000Z"}', '${RIVER_SURVEY_START_KIND}', 3, '${RIVER_SURVEY_LIFECYCLE_QUEUE}'),
('completed', '{"surveyId":"survey_1","environmentId":"env_1","scheduledFor":"2026-04-02T12:00:00.000Z"}', '${RIVER_SURVEY_END_KIND}', 3, '${RIVER_SURVEY_LIFECYCLE_QUEUE}'),
('retryable', '{"surveyId":"survey_2","environmentId":"env_1","scheduledFor":"2026-04-03T12:00:00.000Z"}', '${RIVER_SURVEY_START_KIND}', 3, '${RIVER_SURVEY_LIFECYCLE_QUEUE}')
`)
);
await integrationPrisma.$transaction(async (tx) => {
await deleteSurveyLifecycleJobs({
tx,
surveyId,
schema,
});
});
const remainingJobs = await integrationPrisma.$queryRaw<
Array<{ state: string; kind: string; args: { surveyId: string } }>
>(
Prisma.raw(
`SELECT state, kind, args
FROM ${quoteIdentifier(schema)}.${quoteIdentifier("river_job")}
ORDER BY state ASC, kind ASC`
)
);
expect(remainingJobs).toEqual([
{
state: "completed",
kind: RIVER_SURVEY_END_KIND,
args: {
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: "2026-04-02T12:00:00.000Z",
},
},
{
state: "retryable",
kind: RIVER_SURVEY_START_KIND,
args: {
surveyId: "survey_2",
environmentId: "env_1",
scheduledFor: "2026-04-03T12:00:00.000Z",
},
},
]);
});
});
-181
View File
@@ -1,181 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
RIVER_INSERT_NOTIFICATION_CHANNEL,
RIVER_PENDING_JOB_STATES,
RIVER_SCHEMA,
RIVER_SURVEY_END_KIND,
RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS,
RIVER_SURVEY_LIFECYCLE_QUEUE,
RIVER_SURVEY_START_KIND,
} from "./constants";
export type SurveyLifecycleJobKind = typeof RIVER_SURVEY_START_KIND | typeof RIVER_SURVEY_END_KIND;
export interface SurveyLifecycleJobArgs {
surveyId: string;
environmentId: string;
scheduledFor: string;
}
export interface SurveyLifecycleSurvey {
id: TSurvey["id"];
environmentId: TSurvey["environmentId"];
startsAt?: TSurvey["startsAt"];
endsAt?: TSurvey["endsAt"];
}
interface EnqueueSurveyLifecycleJobsOptions {
tx: Prisma.TransactionClient;
survey: SurveyLifecycleSurvey;
previousSurvey?: Pick<SurveyLifecycleSurvey, "startsAt" | "endsAt"> | null;
now?: Date;
schema?: string;
}
interface DeleteSurveyLifecycleJobsOptions {
tx: Prisma.TransactionClient;
surveyId: string;
schema?: string;
kinds?: SurveyLifecycleJobKind[];
}
const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
const quoteIdentifier = (identifier: string): string => {
if (!identifierPattern.test(identifier)) {
throw new Error(`Invalid SQL identifier: ${identifier}`);
}
return `"${identifier}"`;
};
const getQualifiedRiverJobTable = (schema: string): Prisma.Sql =>
Prisma.raw(`${quoteIdentifier(schema)}.${quoteIdentifier("river_job")}`);
const getQualifiedInsertNotificationChannel = (schema: string): string => {
if (!identifierPattern.test(schema)) {
throw new Error(`Invalid SQL identifier: ${schema}`);
}
return `${schema}.${RIVER_INSERT_NOTIFICATION_CHANNEL}`;
};
const shouldEnqueueTransition = (previousValue?: Date | null, nextValue?: Date | null): nextValue is Date =>
previousValue == null && nextValue != null;
const buildJobArgs = (survey: SurveyLifecycleSurvey, scheduledFor: Date): SurveyLifecycleJobArgs => ({
surveyId: survey.id,
environmentId: survey.environmentId,
scheduledFor: scheduledFor.toISOString(),
});
const enqueueLifecycleJob = async (
tx: Prisma.TransactionClient,
{
kind,
survey,
scheduledFor,
schema,
now,
}: {
kind: SurveyLifecycleJobKind;
survey: SurveyLifecycleSurvey;
scheduledFor: Date;
schema: string;
now: Date;
}
): Promise<void> => {
const args = JSON.stringify(buildJobArgs(survey, scheduledFor));
const riverJobTable = getQualifiedRiverJobTable(schema);
if (scheduledFor.getTime() > now.getTime()) {
await tx.$executeRaw(
Prisma.sql`
INSERT INTO ${riverJobTable} (args, kind, max_attempts, queue, scheduled_at)
VALUES (
${args}::jsonb,
${kind},
${RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS},
${RIVER_SURVEY_LIFECYCLE_QUEUE},
${scheduledFor}
)
`
);
} else {
await tx.$executeRaw(
Prisma.sql`
INSERT INTO ${riverJobTable} (args, kind, max_attempts, queue)
VALUES (
${args}::jsonb,
${kind},
${RIVER_SURVEY_LIFECYCLE_MAX_ATTEMPTS},
${RIVER_SURVEY_LIFECYCLE_QUEUE}
)
`
);
}
};
const notifyLifecycleQueue = async (tx: Prisma.TransactionClient, schema: string): Promise<void> => {
const payload = JSON.stringify({ queue: RIVER_SURVEY_LIFECYCLE_QUEUE });
await tx.$executeRaw(
Prisma.sql`SELECT pg_notify(${getQualifiedInsertNotificationChannel(schema)}, ${payload})`
);
};
export const enqueueSurveyLifecycleJobs = async ({
tx,
survey,
previousSurvey,
now = new Date(),
schema = RIVER_SCHEMA,
}: EnqueueSurveyLifecycleJobsOptions): Promise<void> => {
const pendingJobs: Array<{ kind: SurveyLifecycleJobKind; scheduledFor: Date }> = [];
if (shouldEnqueueTransition(previousSurvey?.startsAt ?? null, survey.startsAt ?? null)) {
pendingJobs.push({ kind: RIVER_SURVEY_START_KIND, scheduledFor: survey.startsAt as Date });
}
if (shouldEnqueueTransition(previousSurvey?.endsAt ?? null, survey.endsAt ?? null)) {
pendingJobs.push({ kind: RIVER_SURVEY_END_KIND, scheduledFor: survey.endsAt as Date });
}
if (pendingJobs.length === 0) {
return;
}
try {
for (const job of pendingJobs) {
await enqueueLifecycleJob(tx, { ...job, survey, schema, now });
}
await notifyLifecycleQueue(tx, schema);
} catch (error) {
logger.error({ error, surveyId: survey.id }, "Failed to enqueue survey lifecycle jobs");
throw error;
}
};
export const deleteSurveyLifecycleJobs = async ({
tx,
surveyId,
schema = RIVER_SCHEMA,
kinds = [RIVER_SURVEY_START_KIND, RIVER_SURVEY_END_KIND],
}: DeleteSurveyLifecycleJobsOptions): Promise<void> => {
try {
await tx.$executeRaw(
Prisma.sql`
DELETE FROM ${getQualifiedRiverJobTable(schema)}
WHERE kind IN (${Prisma.join(kinds)})
AND args->>'surveyId' = ${surveyId}
AND state IN (${Prisma.join(RIVER_PENDING_JOB_STATES)})
`
);
} catch (error) {
logger.error({ error, surveyId }, "Failed to delete pending survey lifecycle jobs");
throw error;
}
};
@@ -193,8 +193,6 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
const baseSurveyProperties = {
id: mockId,
name: "Mock Survey",
startsAt: null,
endsAt: null,
autoClose: 10,
delay: 0,
autoComplete: 7,
+2 -126
View File
@@ -1,17 +1,11 @@
import { prisma } from "../__mocks__/database";
import { prisma } from "@/lib/__mocks__/database";
import { ActionClass, Prisma, Survey } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TActionClass } from "@formbricks/types/action-classes";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getActionClasses } from "@/lib/actionClass/service";
@@ -19,7 +13,6 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { enqueueSurveyLifecycleJobs } from "@/lib/river/survey-lifecycle";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import {
mockActionClass,
@@ -43,8 +36,6 @@ import {
updateSurveyInternal,
} from "./service";
vi.mock("server-only", () => ({}));
// Mock organization service
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn().mockResolvedValue({
@@ -58,21 +49,8 @@ vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/river/survey-lifecycle", () => ({
enqueueSurveyLifecycleJobs: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
beforeEach(() => {
prisma.survey.count.mockResolvedValue(1);
prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma));
vi.mocked(enqueueSurveyLifecycleJobs).mockReset();
vi.mocked(logger.error).mockReset();
});
describe("evaluateLogic with mockSurveyWithLogic", () => {
@@ -331,35 +309,6 @@ describe("Tests for updateSurvey", () => {
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
});
test("enqueues lifecycle jobs when a date is assigned for the first time", async () => {
const startsAt = new Date("2026-04-01T12:00:00.000Z");
const currentSurvey = { ...mockSurveyOutput, startsAt: null, endsAt: null };
const persistedSurvey = { ...mockSurveyOutput, startsAt, endsAt: null };
prisma.survey.findUnique.mockResolvedValueOnce(currentSurvey);
prisma.survey.update.mockResolvedValueOnce(persistedSurvey);
await updateSurvey({
...updateSurveyInput,
startsAt,
endsAt: null,
});
expect(enqueueSurveyLifecycleJobs).toHaveBeenCalledWith({
tx: prisma,
survey: {
id: persistedSurvey.id,
environmentId: persistedSurvey.environmentId,
startsAt,
endsAt: null,
},
previousSurvey: {
startsAt: null,
endsAt: null,
},
});
});
// Note: Language handling tests (for languages.length > 0 fix) are covered in
// apps/web/modules/survey/editor/lib/survey.test.ts where we have better control
// over the test mocks. The key fix ensures languages.length > 0 (not > 1) is used.
@@ -392,31 +341,6 @@ describe("Tests for updateSurvey", () => {
prisma.survey.update.mockRejectedValue(new Error(mockErrorMessage));
await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(Error);
});
test("logs and rethrows lifecycle enqueue failures", async () => {
const enqueueError = new Error("enqueue failed");
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
startsAt: null,
endsAt: null,
});
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
});
vi.mocked(enqueueSurveyLifecycleJobs).mockRejectedValueOnce(enqueueError);
await expect(
updateSurvey({
...updateSurveyInput,
startsAt: new Date("2026-04-01T12:00:00.000Z"),
})
).rejects.toThrow(enqueueError);
expect(logger.error).toHaveBeenCalledWith(enqueueError, "Error updating survey");
});
});
});
@@ -723,36 +647,6 @@ describe("Tests for createSurvey", () => {
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
});
test("passes start and end dates to the lifecycle scheduler", async () => {
const startsAt = new Date("2026-04-02T08:00:00.000Z");
const endsAt = new Date("2026-04-03T08:00:00.000Z");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
startsAt,
endsAt,
type: "link",
});
await createSurvey(mockEnvironmentId, {
...mockCreateSurveyInput,
type: "link",
startsAt,
endsAt,
});
expect(enqueueSurveyLifecycleJobs).toHaveBeenCalledWith({
tx: prisma,
survey: {
id: mockSurveyOutput.id,
environmentId: mockSurveyOutput.environmentId,
startsAt,
endsAt,
},
});
});
test("creates a private segment for app surveys", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
@@ -769,10 +663,6 @@ describe("Tests for createSurvey", () => {
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as TSegment);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
type: "app",
});
await createSurvey(mockEnvironmentId, {
...mockCreateSurveyInput,
@@ -835,20 +725,6 @@ describe("Tests for createSurvey", () => {
describe("Sad Path", () => {
testInputValidation(createSurvey, "123#", mockCreateSurveyInput);
test("rejects surveys whose start date is not before the end date", async () => {
const startsAt = new Date("2026-04-03T08:00:00.000Z");
const endsAt = new Date("2026-04-02T08:00:00.000Z");
await expect(
createSurvey(mockEnvironmentId, {
...mockCreateSurveyInput,
type: "link",
startsAt,
endsAt,
})
).rejects.toThrow(ValidationError);
});
test("throws ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(createSurvey(mockEnvironmentId, mockCreateSurveyInput)).rejects.toThrow(
+243 -252
View File
@@ -11,7 +11,6 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { enqueueSurveyLifecycleJobs } from "@/lib/river/survey-lifecycle";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
@@ -33,8 +32,6 @@ export const selectSurvey = {
environmentId: true,
createdBy: true,
status: true,
startsAt: true,
endsAt: true,
welcomeCard: true,
questions: true,
blocks: true,
@@ -303,6 +300,8 @@ export const updateSurveyInternal = async (
try {
const surveyId = updatedSurvey.id;
let data: any = {};
const actionClasses = await getActionClasses(updatedSurvey.environmentId);
const currentSurvey = await getSurvey(surveyId);
@@ -325,139 +324,132 @@ export const updateSurveyInternal = async (
}
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
// Determine languages to add and remove
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id;
// Prepare data for Prisma update
data.languages = {};
// Update existing languages for default value changes
data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
// Add new languages
if (languagesToAdd.length > 0) {
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
// Remove languages no longer associated with the survey
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
}
const prismaSurvey = await prisma.$transaction(async (tx) => {
let data: any = {};
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
// Determine languages to add and remove
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id;
// Prepare data for Prisma update
data.languages = {};
// Update existing languages for default value changes
data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
// Add new languages
if (languagesToAdd.length > 0) {
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}));
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
if (segment) {
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
// Remove languages no longer associated with the survey
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
}
try {
// update the segment:
let updatedInput: Prisma.SegmentUpdateInput = {
...segment,
surveys: undefined,
};
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
if (segment) {
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
try {
// update the segment:
let updatedInput: Prisma.SegmentUpdateInput = {
if (segment.surveys) {
updatedInput = {
...segment,
surveys: undefined,
surveys: {
connect: segment.surveys.map((surveyId) => ({ id: surveyId })),
},
};
if (segment.surveys) {
updatedInput = {
...segment,
surveys: {
connect: segment.surveys.map((segmentSurveyId) => ({ id: segmentSurveyId })),
},
};
}
await tx.segment.update({
where: { id: segment.id },
data: updatedInput,
select: {
surveys: { select: { id: true } },
environmentId: true,
id: true,
},
});
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
}
} else {
if (segment.isPrivate) {
// disconnect the private segment first and then delete:
await tx.segment.update({
where: { id: segment.id },
data: {
surveys: {
disconnect: {
id: surveyId,
},
},
},
});
// delete the private segment:
await tx.segment.delete({
where: {
id: segment.id,
},
});
} else {
await tx.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
disconnect: true,
},
},
});
}
await prisma.segment.update({
where: { id: segment.id },
data: updatedInput,
select: {
surveys: { select: { id: true } },
environmentId: true,
id: true,
},
});
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
}
} else if (type === "app" && !currentSurvey.segment) {
await tx.survey.update({
} else {
if (segment.isPrivate) {
// disconnect the private segment first and then delete:
await prisma.segment.update({
where: { id: segment.id },
data: {
surveys: {
disconnect: {
id: surveyId,
},
},
},
});
// delete the private segment:
await prisma.segment.delete({
where: {
id: segment.id,
},
});
} else {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
disconnect: true,
},
},
});
}
}
} else if (type === "app") {
if (!currentSurvey.segment) {
await prisma.survey.update({
where: {
id: surveyId,
},
@@ -485,98 +477,102 @@ export const updateSurveyInternal = async (
},
});
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
id: followUp.id,
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
// Strip isDraft from elements before saving
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
delete data.createdBy;
const prismaSurvey = await tx.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
await enqueueSurveyLifecycleJobs({
tx,
survey: {
id: prismaSurvey.id,
environmentId: prismaSurvey.environmentId,
startsAt: prismaSurvey.startsAt,
endsAt: prismaSurvey.endsAt,
},
previousSurvey: {
startsAt: currentSurvey.startsAt ?? null,
endsAt: currentSurvey.endsAt ?? null,
},
});
return prismaSurvey;
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
return transformPrismaSurvey<TSurvey>(prismaSurvey);
// Strip isDraft from elements before saving
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
};
delete data.createdBy;
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -655,69 +651,64 @@ export const createSurvey = async (
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.$transaction(async (tx) => {
const createdSurvey = await tx.survey.create({
const survey = await prisma.survey.create({
data: {
...data,
environment: {
connect: {
id: parsedEnvironmentId,
},
},
},
select: selectSurvey,
});
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
const newSegment = await prisma.segment.create({
data: {
...data,
title: survey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: parsedEnvironmentId,
},
},
},
select: selectSurvey,
});
let createdOrUpdatedSurvey = createdSurvey;
// if the survey created is an "app" survey, we also create a private segment for it.
if (createdSurvey.type === "app") {
const newSegment = await tx.segment.create({
data: {
title: createdSurvey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: parsedEnvironmentId,
},
await prisma.survey.update({
where: {
id: survey.id,
},
data: {
segment: {
connect: {
id: newSegment.id,
},
},
});
createdOrUpdatedSurvey = await tx.survey.update({
where: {
id: createdSurvey.id,
},
data: {
segment: {
connect: {
id: newSegment.id,
},
},
},
select: selectSurvey,
});
}
await enqueueSurveyLifecycleJobs({
tx,
survey: {
id: createdOrUpdatedSurvey.id,
environmentId: createdOrUpdatedSurvey.environmentId,
startsAt: createdOrUpdatedSurvey.startsAt,
endsAt: createdOrUpdatedSurvey.endsAt,
},
});
}
return createdOrUpdatedSurvey;
});
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const transformedSurvey: TSurvey = {
...survey,
...(survey.segment && {
segment: {
...survey.segment,
surveys: survey.segment.surveys.map((survey) => survey.id),
},
}),
};
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
return transformPrismaSurvey<TSurvey>(survey);
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error creating survey");
@@ -9,6 +9,10 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -294,4 +298,78 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});
+16 -6
View File
@@ -1,6 +1,7 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -139,8 +140,10 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
}
// Direct IP literal — validate without DNS resolution
@@ -149,12 +152,17 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
@@ -168,9 +176,11 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
}
};
@@ -31,8 +31,6 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
environmentId: true,
questions: true,
blocks: true,
startsAt: true,
endsAt: true,
endings: true,
hiddenFields: true,
variables: true,
@@ -61,8 +59,6 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
displayLimit: true,
autoClose: true,
autoComplete: true,
startsAt: true,
endsAt: true,
surveyClosedMessage: true,
styling: true,
projectOverwrites: true,
+28 -40
View File
@@ -10,6 +10,25 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -300,51 +319,20 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
});
});
+10 -21
View File
@@ -1,3 +1,4 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -13,7 +14,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -31,6 +32,7 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -310,30 +312,17 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});
@@ -0,0 +1,54 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};
+4 -10
View File
@@ -106,10 +106,7 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -128,10 +125,7 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -145,7 +139,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -165,7 +159,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
+2 -2
View File
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
@@ -458,18 +458,21 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
idempotencySuffix: string
subscriptionCount: number
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
);
};
@@ -1264,8 +1267,7 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string,
idempotencySuffix = "reconcile"
organizationId: string
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1342,12 +1344,14 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "active",
limit: 1,
status: "all",
limit: 20,
});
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
}
}
};
@@ -1355,6 +1359,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId);
};
+20 -5
View File
@@ -3,7 +3,7 @@ import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
import { createAccount } from "@/lib/account/service";
import { upsertAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
@@ -23,6 +23,21 @@ import {
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
const syncSsoAccount = async (userId: string, account: Account) => {
await upsertAccount({
userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
});
};
export const handleSsoCallback = async ({
user,
account,
@@ -108,6 +123,7 @@ export const handleSsoCallback = async ({
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
await syncSsoAccount(existingUserWithAccount.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
@@ -133,6 +149,7 @@ export const handleSsoCallback = async ({
);
await updateUser(existingUserWithAccount.id, { email: user.email });
await syncSsoAccount(existingUserWithAccount.id, account);
return true;
}
@@ -154,6 +171,7 @@ export const handleSsoCallback = async ({
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
await syncSsoAccount(existingUserWithEmail.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
@@ -342,6 +360,7 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
await syncSsoAccount(userProfile.id, account);
if (isMultiOrgEnabled) {
contextLogger.debug(
@@ -358,10 +377,6 @@ export const handleSsoCallback = async ({
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -62,7 +63,7 @@ vi.mock("@/modules/ee/sso/lib/team", () => ({
}));
vi.mock("@/lib/account/service", () => ({
createAccount: vi.fn(),
upsertAccount: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
@@ -203,6 +204,36 @@ describe("handleSsoCallback", () => {
});
});
test("should not overwrite stored tokens when the provider omits them", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: {
...mockAccount,
access_token: undefined,
refresh_token: undefined,
expires_at: undefined,
scope: undefined,
token_type: undefined,
id_token: undefined,
},
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith({
userId: mockUser.id,
type: mockAccount.type,
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
});
});
test("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,
@@ -11,9 +11,10 @@ import { AddWebhookModal } from "./add-webhook-modal";
interface AddWebhookButtonProps {
environment: TEnvironment;
surveys: TSurvey[];
allowInternalUrls: boolean;
}
export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps) => {
export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: AddWebhookButtonProps) => {
const { t } = useTranslation();
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
return (
@@ -31,6 +32,7 @@ export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps
surveys={surveys}
open={isAddWebhookModalOpen}
setOpen={setAddWebhookModalOpen}
allowInternalUrls={allowInternalUrls}
/>
</>
);
@@ -34,9 +34,16 @@ interface AddWebhookModalProps {
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
allowInternalUrls: boolean;
}
export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) => {
export const AddWebhookModal = ({
environmentId,
surveys,
open,
setOpen,
allowInternalUrls,
}: AddWebhookModalProps) => {
const router = useRouter();
const {
handleSubmit,
@@ -59,7 +66,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return { success: false };
@@ -23,9 +23,17 @@ interface WebhookModalProps {
webhook: Webhook;
surveys: TSurvey[];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
export const WebhookModal = ({
open,
setOpen,
webhook,
surveys,
isReadOnly,
allowInternalUrls,
}: WebhookModalProps) => {
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
@@ -38,7 +46,13 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
{
title: t("common.settings"),
children: (
<WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} isReadOnly={isReadOnly} />
<WebhookSettingsTab
webhook={webhook}
surveys={surveys}
setOpen={setOpen}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
),
},
];
@@ -26,9 +26,16 @@ interface WebhookSettingsTabProps {
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
export const WebhookSettingsTab = ({
webhook,
surveys,
setOpen,
isReadOnly,
allowInternalUrls,
}: WebhookSettingsTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -60,7 +67,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return false;
@@ -14,6 +14,7 @@ interface WebhookTableProps {
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookTable = ({
@@ -22,6 +23,7 @@ export const WebhookTable = ({
surveys,
children: [TableHeading, webhookRows],
isReadOnly,
allowInternalUrls,
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const { t } = useTranslation();
@@ -71,6 +73,7 @@ export const WebhookTable = ({
webhook={activeWebhook}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
</>
);
@@ -1,4 +1,4 @@
export const validWebHookURL = (urlInput: string) => {
export const validWebHookURL = (urlInput: string, allowInternalUrls = false) => {
const trimmedInput = urlInput.trim();
if (!trimmedInput) {
return { valid: false, error: "Please enter a URL" };
@@ -7,6 +7,13 @@ export const validWebHookURL = (urlInput: string) => {
try {
const url = new URL(trimmedInput);
if (allowInternalUrls) {
if (url.protocol !== "https:" && url.protocol !== "http:") {
return { valid: false, error: "URL must start with https:// or http://" };
}
return { valid: true };
}
if (url.protocol !== "https:") {
return { valid: false, error: "URL must start with https://" };
}
@@ -1,3 +1,4 @@
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -21,13 +22,24 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
]);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const renderAddWebhookButton = () => (
<AddWebhookButton
environment={environment}
surveys={surveys}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}
/>
);
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTable
environment={environment}
webhooks={webhooks}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
@@ -1,43 +1,284 @@
import { ActionClass, Prisma } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey, TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
createSurvey as createSurveyFromService,
handleTriggerUpdates as handleTriggerUpdatesFromService,
} from "@/lib/survey/service";
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { createSurvey, handleTriggerUpdates } from "./survey";
vi.mock("@/lib/survey/service", () => ({
createSurvey: vi.fn(),
handleTriggerUpdates: vi.fn(),
// Mock dependencies
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
describe("template list survey wrappers", () => {
const environmentId = "env_1";
const surveyBody = { name: "Survey" } as TSurveyCreateInput;
const createdSurvey = { id: "survey_1" } as TSurvey;
vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
selectSurvey: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
status: true,
environmentId: true,
segment: true,
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
create: vi.fn(),
update: vi.fn(),
},
segment: {
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("survey module", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
});
test("re-exports the shared trigger update helper", () => {
expect(handleTriggerUpdates).toBe(handleTriggerUpdatesFromService);
describe("createSurvey", () => {
test("creates a survey successfully", async () => {
// Mock input data
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
createdBy: "user-123",
};
// Mock dependencies
const mockActionClasses: ActionClass[] = [];
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123", name: "Org" } as any);
const mockCreatedSurvey = {
id: "survey-123",
environmentId,
type: "app",
segment: {
surveys: [{ id: "survey-123" }],
},
} as any;
vi.mocked(prisma.survey.create).mockResolvedValue(mockCreatedSurvey);
const mockSegment = { id: "segment-123" } as any;
vi.mocked(prisma.segment.create).mockResolvedValue(mockSegment);
// Execute function
const result = await createSurvey(environmentId, surveyBody);
// Verify results
expect(getActionClasses).toHaveBeenCalledWith(environmentId);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(prisma.survey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: surveyBody.name,
type: surveyBody.type,
environment: { connect: { id: environmentId } },
creator: { connect: { id: surveyBody.createdBy } },
}),
select: selectSurvey,
});
expect(prisma.segment.create).toHaveBeenCalled();
expect(prisma.survey.update).toHaveBeenCalled();
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalledWith(
"survey-123",
"user-123",
"org-123"
);
expect(result).toBeDefined();
expect(result.id).toBe("survey-123");
});
test("handles empty languages array", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
languages: [],
questions: [],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(prisma.survey.create).mockResolvedValue({
id: "survey-123",
environmentId,
type: "link",
segment: null,
} as any);
await createSurvey(environmentId, surveyBody);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.not.objectContaining({ languages: [] }),
})
);
});
test("handles follow-ups properly", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
followUps: [{ name: "Follow Up 1", trigger: "trigger1", action: "action1" } as any],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(prisma.survey.create).mockResolvedValue({
id: "survey-123",
environmentId,
type: "link",
segment: null,
} as any);
await createSurvey(environmentId, surveyBody);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
followUps: {
create: [{ name: "Follow Up 1", trigger: "trigger1", action: "action1" }],
},
}),
})
);
});
test("throws error when organization not found", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(ResourceNotFoundError);
});
test("handles database errors", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.survey.create).mockRejectedValue(prismaError);
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
});
test("delegates createSurvey to the shared survey service", async () => {
vi.mocked(createSurveyFromService).mockResolvedValueOnce(createdSurvey);
describe("handleTriggerUpdates", () => {
test("handles empty triggers", () => {
const result = handleTriggerUpdates(undefined as any, [], []);
expect(result).toEqual({});
});
const result = await createSurvey(environmentId, surveyBody);
test("adds new triggers", () => {
const updatedTriggers = [
{ actionClass: { id: "action-1" } },
{ actionClass: { id: "action-2" } },
] as any;
const currentTriggers = [] as any;
const actionClasses = [{ id: "action-1" }, { id: "action-2" }] as ActionClass[];
expect(createSurveyFromService).toHaveBeenCalledWith(environmentId, surveyBody);
expect(result).toBe(createdSurvey);
});
const result = handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses);
test("propagates service errors", async () => {
const error = new DatabaseError("database error");
vi.mocked(createSurveyFromService).mockRejectedValueOnce(error);
expect(result).toEqual({
create: [{ actionClassId: "action-1" }, { actionClassId: "action-2" }],
});
});
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(error);
test("removes triggers", () => {
const updatedTriggers = [] as any;
const currentTriggers = [
{ actionClass: { id: "action-1" } },
{ actionClass: { id: "action-2" } },
] as any;
const actionClasses = [{ id: "action-1" }, { id: "action-2" }] as ActionClass[];
const result = handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses);
expect(result).toEqual({
deleteMany: {
actionClassId: {
in: ["action-1", "action-2"],
},
},
});
});
test("throws error for invalid trigger", () => {
const updatedTriggers = [{ actionClass: { id: "action-3" } }] as any;
const currentTriggers = [] as any;
const actionClasses = [{ id: "action-1" }] as ActionClass[];
expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses)).toThrow(
InvalidInputError
);
});
test("throws error for duplicate triggers", () => {
const updatedTriggers = [
{ actionClass: { id: "action-1" } },
{ actionClass: { id: "action-1" } },
] as any;
const currentTriggers = [] as any;
const actionClasses = [{ id: "action-1" }] as ActionClass[];
expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses)).toThrow(
InvalidInputError
);
});
});
});
@@ -1,11 +1,205 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { createSurvey as createSurveyFromService, handleTriggerUpdates } from "@/lib/survey/service";
export { handleTriggerUpdates };
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
export const createSurvey = async (
environmentId: string,
surveyBody: TSurveyCreateInput
): Promise<TSurvey> => {
return createSurveyFromService(environmentId, surveyBody);
try {
const { createdBy, ...restSurveyBody } = surveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
delete restSurveyBody.languages;
}
const actionClasses = await getActionClasses(environmentId);
// @ts-expect-error
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...restSurveyBody,
// TODO: Create with attributeFilters
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
// Validate and prepare blocks
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
environment: {
connect: {
id: environmentId,
},
},
},
select: selectSurvey,
});
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
const newSegment = await prisma.segment.create({
data: {
title: survey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: environmentId,
},
},
},
});
await prisma.survey.update({
where: {
id: survey.id,
},
data: {
segment: {
connect: {
id: newSegment.id,
},
},
},
});
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const transformedSurvey: TSurvey = {
...survey,
...(survey.segment && {
segment: {
...survey.segment,
surveys: survey.segment.surveys.map((survey) => survey.id),
},
}),
};
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error creating survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
const getTriggerIds = (triggers: unknown): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
return triggers.map((trigger) => {
const actionClassId = (trigger as { actionClass?: { id?: unknown } })?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
const checkTriggersValidity = (triggers: unknown, actionClasses: Array<{ id: string }>) => {
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
export const handleTriggerUpdates = (
updatedTriggers: unknown,
currentTriggers: unknown,
actionClasses: Array<{ id: string }>
) => {
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggerIds,
},
};
}
return triggersUpdate;
};
+813 -35
View File
@@ -1,59 +1,837 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
handleTriggerUpdates as handleTriggerUpdatesFromService,
updateSurvey as updateSurveyFromService,
updateSurveyInternal,
} from "@/lib/survey/service";
import { handleTriggerUpdates, updateSurvey, updateSurveyDraft } from "./survey";
import { ActionClass, Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { updateSurveyInternal } from "@/lib/survey/service";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey } from "@/modules/survey/lib/survey";
import { checkTriggersValidity, handleTriggerUpdates, updateSurvey, updateSurveyDraft } from "./survey";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
update: vi.fn(),
},
segment: {
update: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
handleTriggerUpdates: vi.fn(),
updateSurvey: vi.fn(),
updateSurveyInternal: vi.fn(),
}));
describe("survey editor wrappers", () => {
const survey = { id: "survey_1" } as TSurvey;
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
beforeEach(() => {
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationAIKeys: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
selectSurvey: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
environmentId: true,
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Editor Library Tests", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("re-exports the shared trigger update helper", () => {
expect(handleTriggerUpdates).toBe(handleTriggerUpdatesFromService);
describe("updateSurvey", () => {
const mockSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "env123",
createdBy: "user123",
status: "draft",
displayOption: "displayOnce",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
welcomeCard: {
enabled: false,
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
endings: [],
hiddenFields: { enabled: false },
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: false,
segment: null,
surveyClosedMessage: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
displayPercentage: null,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
],
variables: [],
followUps: [],
} as unknown as TSurvey;
const mockCurrentSurvey = { ...mockSurvey };
const mockActionClasses: ActionClass[] = [
{
id: "action1",
name: "Code Action",
description: "Action from code",
type: "code" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
];
const mockOrganizationId = "org123";
const mockOrganization = {
id: mockOrganizationId,
name: "Test Organization",
ownerUserId: "user123",
billing: {
stripeCustomerId: "cust_123",
features: {},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
};
beforeEach(() => {
vi.mocked(prisma.survey.update).mockResolvedValue(mockSurvey as any);
vi.mocked(prisma.segment.update).mockResolvedValue({
id: "segment1",
environmentId: "env123",
surveys: [{ id: "survey123" }],
} as any);
vi.mocked(getSurvey).mockResolvedValue(mockCurrentSurvey);
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(mockOrganizationId);
vi.mocked(getOrganizationAIKeys).mockResolvedValue(mockOrganization as any);
});
test("should handle languages update with multiple languages", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
{
language: {
id: "es",
code: "es",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: false,
enabled: true,
},
],
};
await updateSurvey(updatedSurvey);
expect(prisma.survey.update).toHaveBeenCalledWith({
where: { id: "survey123" },
data: expect.objectContaining({
languages: {
updateMany: expect.any(Array),
create: expect.arrayContaining([
expect.objectContaining({
languageId: "es",
default: false,
enabled: true,
}),
]),
},
}),
select: expect.any(Object),
});
});
test("should handle languages update with single default language", async () => {
// This tests the fix for the bug where languages.length === 1 would incorrectly
// set updatedLanguageIds to [] causing the default language to be removed
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
],
};
await updateSurvey(updatedSurvey);
// Verify that prisma.survey.update was called
expect(prisma.survey.update).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
// The key test: when languages.length === 1, we should still process language updates
// and NOT delete the language. Before the fix, languages.length > 1 would fail this case.
expect(updateCall).toBeDefined();
expect(updateCall.where).toEqual({ id: "survey123" });
expect(updateCall.data).toBeDefined();
});
test("should remove all languages when empty array is passed", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [],
};
await updateSurvey(updatedSurvey);
// Verify that prisma.survey.update was called
expect(prisma.survey.update).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
// When languages is empty array, all existing languages should be removed
expect(updateCall).toBeDefined();
expect(updateCall.where).toEqual({ id: "survey123" });
expect(updateCall.data).toBeDefined();
});
test("should delete private segment for non-app type surveys", async () => {
const mockSegment: TSegment = {
id: "segment1",
title: "Test Segment",
isPrivate: true,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: [{ id: "filter1" } as any],
};
const updatedSurvey: TSurvey = {
...mockSurvey,
type: "link",
segment: mockSegment,
};
await updateSurvey(updatedSurvey);
expect(prisma.segment.update).toHaveBeenCalledWith({
where: { id: "segment1" },
data: {
surveys: {
disconnect: {
id: "survey123",
},
},
},
});
expect(prisma.segment.delete).toHaveBeenCalledWith({
where: {
id: "segment1",
},
});
});
test("should disconnect public segment for non-app type surveys", async () => {
const mockSegment: TSegment = {
id: "segment1",
title: "Test Segment",
isPrivate: false,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: [],
};
const updatedSurvey: TSurvey = {
...mockSurvey,
type: "link",
segment: mockSegment,
};
await updateSurvey(updatedSurvey);
expect(prisma.survey.update).toHaveBeenCalledWith({
where: {
id: "survey123",
},
data: {
segment: {
disconnect: true,
},
},
});
});
test("should handle followUps updates", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
followUps: [
{
id: "f1",
name: "Existing Follow Up",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey123",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "test@example.com",
subject: "Test",
body: "Test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
deleted: false,
},
{
id: "f2",
name: "New Follow Up",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey123",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "new@example.com",
subject: "New Test",
body: "New test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
deleted: false,
},
{
id: "f3",
name: "Follow Up To Delete",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey123",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "delete@example.com",
subject: "Delete Test",
body: "Delete test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
deleted: true,
},
],
};
// Mock current survey with existing followUps
vi.mocked(getSurvey).mockResolvedValueOnce({
...mockCurrentSurvey,
followUps: [
{
id: "f1",
name: "Existing Follow Up",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "test@example.com",
subject: "Test",
body: "Test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
},
],
} as any);
await updateSurvey(updatedSurvey);
expect(prisma.survey.update).toHaveBeenCalledWith({
where: { id: "survey123" },
data: expect.objectContaining({
followUps: {
updateMany: [
{
where: {
id: "f1",
},
data: expect.objectContaining({
name: "Existing Follow Up",
}),
},
],
createMany: {
data: [
expect.objectContaining({
name: "New Follow Up",
}),
],
},
deleteMany: [
{
id: "f3",
},
],
},
}),
select: expect.any(Object),
});
});
test("should throw ResourceNotFoundError when survey is not found", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
expect(getSurvey).toHaveBeenCalledWith("survey123");
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(getOrganizationAIKeys).mockResolvedValueOnce(null);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError when Prisma throws a known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.update).mockRejectedValueOnce(prismaError);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(DatabaseError);
});
test("should rethrow other errors", async () => {
const genericError = new Error("Some other error");
vi.mocked(prisma.survey.update).mockRejectedValueOnce(genericError);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(genericError);
});
test("should throw InvalidInputError for invalid segment filters", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
segment: {
id: "segment1",
title: "Test Segment",
isPrivate: false,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: "invalid filters" as any,
},
};
await expect(updateSurvey(updatedSurvey)).rejects.toThrow(InvalidInputError);
});
test("should handle error in segment update", async () => {
vi.mocked(prisma.segment.update).mockRejectedValueOnce(new Error("Error updating survey"));
const updatedSurvey: TSurvey = {
...mockSurvey,
segment: {
id: "segment1",
title: "Test Segment",
isPrivate: false,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: [],
},
};
await expect(updateSurvey(updatedSurvey)).rejects.toThrow("Error updating survey");
});
});
test("delegates updateSurvey to the shared survey service", async () => {
vi.mocked(updateSurveyFromService).mockResolvedValueOnce(survey);
describe("checkTriggersValidity", () => {
const mockActionClasses: ActionClass[] = [
{
id: "action1",
name: "Action 1",
description: "Test Action 1",
type: "code" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
{
id: "action2",
name: "Action 2",
description: "Test Action 2",
type: "noCode" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
];
const result = await updateSurvey(survey);
const createFullActionClass = (id: string, type: "code" | "noCode" = "code"): ActionClass => ({
id,
name: `Action ${id}`,
description: `Test Action ${id}`,
type,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
});
expect(updateSurveyFromService).toHaveBeenCalledWith(survey);
expect(result).toBe(survey);
test("should not throw error for valid triggers", () => {
const triggers = [
{ actionClass: createFullActionClass("action1") },
{ actionClass: createFullActionClass("action2", "noCode") },
];
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).not.toThrow();
});
test("should throw error for invalid trigger id", () => {
const triggers = [
{ actionClass: createFullActionClass("action1") },
{ actionClass: createFullActionClass("invalid") },
];
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow(InvalidInputError);
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow("Invalid trigger id");
});
test("should throw error for duplicate trigger ids", () => {
const triggers = [
{ actionClass: createFullActionClass("action1") },
{ actionClass: createFullActionClass("action1") },
];
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow(InvalidInputError);
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow("Duplicate trigger id");
});
test("should do nothing when triggers are undefined", () => {
expect(() => checkTriggersValidity(undefined as any, mockActionClasses)).not.toThrow();
});
});
test("delegates draft saves to updateSurveyInternal with skipValidation enabled", async () => {
vi.mocked(updateSurveyInternal).mockResolvedValueOnce(survey);
describe("handleTriggerUpdates", () => {
const mockActionClasses: ActionClass[] = [
{
id: "action1",
name: "Action 1",
description: "Test Action 1",
type: "code" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
{
id: "action2",
name: "Action 2",
description: "Test Action 2",
type: "noCode" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
{
id: "action3",
name: "Action 3",
description: "Test Action 3",
type: "noCode" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
];
const result = await updateSurveyDraft(survey);
const createActionClassObj = (id: string, type: "code" | "noCode" = "code"): ActionClass => ({
id,
name: `Action ${id}`,
description: `Test Action ${id}`,
type,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
});
expect(updateSurveyInternal).toHaveBeenCalledWith(survey, true);
expect(result).toBe(survey);
test("should return empty object when updatedTriggers is undefined", () => {
const result = handleTriggerUpdates(undefined as any, [], mockActionClasses);
expect(result).toEqual({});
});
test("should identify added triggers correctly", () => {
const currentTriggers = [{ actionClass: createActionClassObj("action1") }];
const updatedTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action2", "noCode") },
];
const result = handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses);
expect(result).toEqual({
create: [{ actionClassId: "action2" }],
});
});
test("should identify deleted triggers correctly", () => {
const currentTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action2", "noCode") },
];
const updatedTriggers = [{ actionClass: createActionClassObj("action1") }];
const result = handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses);
expect(result).toEqual({
deleteMany: {
actionClassId: {
in: ["action2"],
},
},
});
});
test("should handle both added and deleted triggers", () => {
const currentTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action2", "noCode") },
];
const updatedTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action3", "noCode") },
];
const result = handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses);
expect(result).toEqual({
create: [{ actionClassId: "action3" }],
deleteMany: {
actionClassId: {
in: ["action2"],
},
},
});
});
test("should validate triggers before processing", () => {
const currentTriggers = [{ actionClass: createActionClassObj("action1") }];
const updatedTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("invalid") },
];
expect(() =>
handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses)
).toThrow(InvalidInputError);
});
});
test("propagates service errors for updateSurvey", async () => {
const error = new DatabaseError("database error");
vi.mocked(updateSurveyFromService).mockRejectedValueOnce(error);
describe("updateSurveyDraft", () => {
const mockSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Draft Survey",
type: "app",
environmentId: "env123",
createdBy: "user123",
status: "draft",
displayOption: "displayOnce",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
welcomeCard: {
enabled: false,
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
endings: [],
hiddenFields: { enabled: false },
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: false,
segment: null,
surveyClosedMessage: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
displayPercentage: null,
languages: [],
variables: [],
followUps: [],
} as unknown as TSurvey;
await expect(updateSurvey(survey)).rejects.toThrow(error);
});
beforeEach(() => {
vi.mocked(updateSurveyInternal).mockResolvedValue(mockSurvey);
});
test("propagates service errors for updateSurveyDraft", async () => {
const error = new ResourceNotFoundError("Survey", "survey_1");
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
test("should call updateSurveyInternal with skipValidation=true", async () => {
await updateSurveyDraft(mockSurvey);
await expect(updateSurveyDraft(survey)).rejects.toThrow(error);
expect(updateSurveyInternal).toHaveBeenCalledWith(mockSurvey, true);
expect(updateSurveyInternal).toHaveBeenCalledTimes(1);
});
test("should return the survey from updateSurveyInternal", async () => {
const result = await updateSurveyDraft(mockSurvey);
expect(result).toEqual(mockSurvey);
});
test("should propagate errors from updateSurveyInternal", async () => {
const error = new Error("Internal update failed");
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow("Internal update failed");
});
test("should propagate ResourceNotFoundError from updateSurveyInternal", async () => {
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey123"));
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
});
test("should propagate DatabaseError from updateSurveyInternal", async () => {
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new DatabaseError("Database connection failed"));
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(DatabaseError);
});
});
});
+349 -8
View File
@@ -1,16 +1,357 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
handleTriggerUpdates,
updateSurvey as updateSurveyFromService,
updateSurveyInternal,
} from "@/lib/survey/service";
export { handleTriggerUpdates };
import { updateSurveyInternal } from "@/lib/survey/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
// Use internal version with skipValidation=true to allow incomplete drafts
return updateSurveyInternal(updatedSurvey, true);
};
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
return updateSurveyFromService(updatedSurvey);
try {
const surveyId = updatedSurvey.id;
let data: any = {};
const actionClasses = await getActionClasses(updatedSurvey.environmentId);
const currentSurvey = await getSurvey(surveyId);
if (!currentSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
// Validate and prepare blocks for persistence
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks);
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
// Determine languages to add and remove
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id;
// Prepare data for Prisma update
data.languages = {};
// Update existing languages for default value changes
data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
// Add new languages
if (languagesToAdd.length > 0) {
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
// Remove languages no longer associated with the survey
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
}
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
if (segment) {
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
try {
// update the segment:
let updatedInput: Prisma.SegmentUpdateInput = {
...segment,
surveys: undefined,
};
if (segment.surveys) {
updatedInput = {
...segment,
surveys: {
connect: segment.surveys.map((surveyId) => ({ id: surveyId })),
},
};
}
await prisma.segment.update({
where: { id: segment.id },
data: updatedInput,
select: {
surveys: { select: { id: true } },
environmentId: true,
id: true,
},
});
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
}
} else {
if (segment.isPrivate) {
// disconnect the private segment first and then delete:
await prisma.segment.update({
where: { id: segment.id },
data: {
surveys: {
disconnect: {
id: surveyId,
},
},
},
});
// delete the private segment:
await prisma.segment.delete({
where: {
id: segment.id,
},
});
} else {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
disconnect: true,
},
},
});
}
}
} else if (type === "app") {
if (!currentSurvey.segment) {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
connectOrCreate: {
where: {
environmentId_title: {
environmentId,
title: surveyId,
},
},
create: {
title: surveyId,
isPrivate: true,
filters: [],
environment: {
connect: {
id: environmentId,
},
},
},
},
},
},
});
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
id: followUp.id,
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organization = await getOrganizationAIKeys(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
};
delete data.createdBy;
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error updating survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
const getTriggerIds = (triggers: unknown): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
return triggers.map((trigger) => {
const actionClassId = (trigger as { actionClass?: { id?: unknown } })?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
export const checkTriggersValidity = (triggers: unknown, actionClasses: Array<{ id: string }>) => {
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
export const handleTriggerUpdates = (
updatedTriggers: unknown,
currentTriggers: unknown,
actionClasses: Array<{ id: string }>
) => {
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggerIds,
},
};
}
return triggersUpdate;
};
-2
View File
@@ -16,8 +16,6 @@ export const selectSurvey = {
environmentId: true,
createdBy: true,
status: true,
startsAt: true,
endsAt: true,
welcomeCard: true,
questions: true,
blocks: true,
@@ -7,7 +7,6 @@ import { logger } from "@formbricks/logger";
import { TActionClassType } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { deleteSurveyLifecycleJobs } from "@/lib/river/survey-lifecycle";
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -26,8 +25,6 @@ import {
} from "./survey";
import { surveySelect } from "./survey-record";
vi.mock("server-only", () => ({}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
@@ -40,10 +37,6 @@ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidMediaInBlocks: vi.fn(() => ({ ok: true, data: undefined })),
}));
vi.mock("@/lib/river/survey-lifecycle", () => ({
deleteSurveyLifecycleJobs: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
@@ -83,7 +76,6 @@ vi.mock("@/lingodotdev/server", () => ({
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
survey: {
findMany: vi.fn(),
findUnique: vi.fn(),
@@ -134,11 +126,9 @@ const resetMocks = () => {
vi.mocked(prisma.survey.count).mockReset();
vi.mocked(prisma.survey.delete).mockReset();
vi.mocked(prisma.survey.create).mockReset();
vi.mocked(prisma.$transaction).mockReset();
vi.mocked(prisma.segment.delete).mockReset();
vi.mocked(prisma.segment.findFirst).mockReset();
vi.mocked(prisma.actionClass.findMany).mockReset();
vi.mocked(deleteSurveyLifecycleJobs).mockReset();
vi.mocked(logger.error).mockClear();
};
@@ -433,7 +423,6 @@ describe("getSurveysSortedByRelevance", () => {
describe("deleteSurvey", () => {
beforeEach(() => {
resetMocks();
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback(prisma));
});
const mockDeletedSurveyData = {
@@ -449,7 +438,6 @@ describe("deleteSurvey", () => {
const result = await deleteSurvey(surveyId);
expect(result).toBe(true);
expect(deleteSurveyLifecycleJobs).toHaveBeenCalledWith({ tx: prisma, surveyId });
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
select: expect.objectContaining({ id: true, environmentId: true, segment: expect.anything() }),
@@ -466,20 +454,19 @@ describe("deleteSurvey", () => {
await deleteSurvey(surveyId);
expect(deleteSurveyLifecycleJobs).toHaveBeenCalledWith({ tx: prisma, surveyId });
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error deleting survey");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.$transaction).mockRejectedValue(unknownError);
vi.mocked(prisma.survey.delete).mockRejectedValue(unknownError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(unknownError);
});
});
+27 -34
View File
@@ -8,7 +8,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { deleteSurveyLifecycleJobs } from "@/lib/river/survey-lifecycle";
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { getTranslate } from "@/lingodotdev/server";
@@ -148,46 +147,40 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
try {
const deletedSurvey = await prisma.$transaction(async (tx) => {
await deleteSurveyLifecycleJobs({ tx, surveyId });
const removedSurvey = await tx.survey.delete({
where: {
id: surveyId,
},
select: {
id: true,
environmentId: true,
segment: {
select: {
id: true,
isPrivate: true,
},
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
select: {
id: true,
environmentId: true,
segment: {
select: {
id: true,
isPrivate: true,
},
type: true,
triggers: {
select: {
actionClass: {
select: {
id: true,
},
},
type: true,
triggers: {
select: {
actionClass: {
select: {
id: true,
},
},
},
},
});
if (removedSurvey.type === "app" && removedSurvey.segment?.isPrivate) {
await tx.segment.delete({
where: {
id: removedSurvey.segment.id,
},
});
}
return removedSurvey;
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -42,14 +42,14 @@ export interface ButtonProps
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
({ className, variant, size, loading, asChild = false, disabled, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, loading, className }))}
disabled={loading}
ref={ref}
{...props}>
{...props}
disabled={loading || disabled}>
{loading ? (
<>
<Loader2 className="animate-spin" />
+2 -1
View File
@@ -42,6 +42,7 @@
"@lexical/react": "0.41.0",
"@lexical/rich-text": "0.41.0",
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.213.0",
@@ -99,7 +100,7 @@
"next-auth": "4.24.13",
"next-safe-action": "8.1.8",
"node-fetch": "3.3.2",
"nodemailer": "8.0.2",
"nodemailer": "8.0.4",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",
@@ -485,5 +485,55 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
test("should invalidate a copied session cookie after logout", async ({ page, browser, users }) => {
const user = await users.create();
await user.login();
const sessionCookie = (await page.context().cookies()).find((cookie) =>
cookie.name.includes("next-auth.session-token")
);
expect(sessionCookie).toBeDefined();
const preLogoutContext = await browser.newContext();
try {
await preLogoutContext.addCookies([sessionCookie!]);
const preLogoutPage = await preLogoutContext.newPage();
await preLogoutPage.goto("http://localhost:3000/environments");
await expect(preLogoutPage).not.toHaveURL(/\/auth\/login/);
} finally {
await preLogoutContext.close();
}
const signOutCsrfToken = await page
.context()
.request.get("/api/auth/csrf")
.then((response) => response.json())
.then((json) => json.csrfToken);
const signOutResponse = await page.context().request.post("/api/auth/signout", {
form: {
callbackUrl: "/auth/login",
csrfToken: signOutCsrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
expect(signOutResponse.status()).not.toBe(500);
const replayContext = await browser.newContext();
try {
await replayContext.addCookies([sessionCookie!]);
const replayPage = await replayContext.newPage();
await replayPage.goto("http://localhost:3000/environments");
await expect(replayPage).toHaveURL(/\/auth\/login/);
} finally {
await replayContext.close();
}
});
});
});
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { proxy } from "./proxy";
const { mockGetProxySession, mockIsPublicDomainConfigured, mockIsRequestFromPublicDomain } = vi.hoisted(
() => ({
mockGetProxySession: vi.fn(),
mockIsPublicDomainConfigured: vi.fn(),
mockIsRequestFromPublicDomain: vi.fn(),
})
);
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/app/middleware/domain-utils", () => ({
isPublicDomainConfigured: mockIsPublicDomainConfigured,
isRequestFromPublicDomain: mockIsRequestFromPublicDomain,
}));
vi.mock("@/app/middleware/endpoint-validator", () => ({
isAuthProtectedRoute: (url: string) => url.startsWith("/environments"),
isRouteAllowedForDomain: vi.fn(() => true),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/utils/url", () => ({
isValidCallbackUrl: (url: string) => url.startsWith("http://localhost:3000"),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPublicDomainConfigured.mockReturnValue(false);
mockIsRequestFromPublicDomain.mockReturnValue(false);
});
test("redirects unauthenticated protected routes to login with callbackUrl", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(new NextRequest("http://localhost:3000/environments/test"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
);
});
test("rejects invalid callback URLs", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(
new NextRequest("http://localhost:3000/auth/login?callbackUrl=https%3A%2F%2Fevil.example")
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid callback URL" });
});
test("redirects authenticated callback requests to the callback URL", async () => {
mockGetProxySession.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
});
const response = await proxy(
new NextRequest(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
)
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/environments/test");
});
});
+4 -4
View File
@@ -1,4 +1,3 @@
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
@@ -6,11 +5,12 @@ import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middl
import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator";
import { WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
const session = await getProxySession(request);
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
if (isAuthProtectedRoute(request.nextUrl.pathname) && !session) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
@@ -21,7 +21,7 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (token && callbackUrl) {
if (session && callbackUrl) {
return NextResponse.redirect(callbackUrl);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts"],
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts", "proxy.ts"],
exclude: [
// Build and configuration files
"**/.next/**", // Next.js build output
@@ -32,6 +32,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
@@ -70,6 +70,18 @@ endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
workflow while validating the webhook setup.
</Note>
### Allowing Internal URLs (Self-Hosted Only)
By default, Formbricks blocks webhook URLs that point to private or internal IP addresses (e.g. `localhost`, `192.168.x.x`, `10.x.x.x`) to prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server-Side_Request_Forgery). If you are self-hosting Formbricks and need to send webhooks to internal services, you can set the following environment variable:
```sh
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
```
<Warning>
Only enable this on trusted, self-hosted environments. Enabling this on a publicly accessible instance exposes your server to SSRF risks.
</Warning>
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
---
+11 -4
View File
@@ -4,8 +4,7 @@
"private": true,
"workspaces": [
"apps/*",
"packages/*",
"services/*"
"packages/*"
],
"prisma": {
"schema": "packages/database/schema.prisma"
@@ -92,18 +91,26 @@
"flatted": "3.4.2",
"hono": "4.12.7",
"@microsoft/api-extractor>minimatch": "10.2.4",
"node-forge": ">=1.3.2",
"minimatch@3.1.5": "file:vendor/minimatch-3.1.5",
"node-forge": "1.4.0",
"brace-expansion@5.0.4": "5.0.5",
"minimatch@3.1.5>brace-expansion": "5.0.5",
"minimatch@9.0.9": "10.2.4",
"lodash": "4.17.23",
"picomatch@2.3.1": "2.3.2",
"picomatch@4.0.3": "4.0.4",
"qs": "6.14.2",
"rollup": "4.59.0",
"socket.io-parser": "4.2.6",
"tar": ">=7.5.11",
"typeorm": ">=0.3.26",
"undici": "7.24.0",
"yaml": "2.8.3",
"fast-xml-parser": "5.5.7",
"diff": ">=8.0.3"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #347/#348/#349/#350) - awaiting @boxyhq/saml-jackson update | brace-expansion (Dependabot #346 / npm audit) - awaiting upstream adoption of safe minimatch/brace-expansion combos in transitive tooling and @boxyhq/saml-jackson | lodash (npm audit) - awaiting @boxyhq/saml-jackson update | picomatch (Dependabot #342/#343) - awaiting Vite/Vitest/lint-staged patch adoption | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | yaml (Dependabot #344) - awaiting Vite/lint-staged patch adoption | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,213 +0,0 @@
ALTER TABLE "Survey"
ADD COLUMN "startsAt" TIMESTAMP(3),
ADD COLUMN "endsAt" TIMESTAMP(3);
ALTER TABLE "Survey"
ADD CONSTRAINT "Survey_startsAt_before_endsAt"
CHECK ("startsAt" IS NULL OR "endsAt" IS NULL OR "startsAt" < "endsAt");
CREATE SCHEMA IF NOT EXISTS "river";
-- River main migration 002 [up]
CREATE TYPE river.river_job_state AS ENUM(
'available',
'cancelled',
'completed',
'discarded',
'retryable',
'running',
'scheduled'
);
CREATE TABLE river.river_job(
id bigserial PRIMARY KEY,
state river.river_job_state NOT NULL DEFAULT 'available',
attempt smallint NOT NULL DEFAULT 0,
max_attempts smallint NOT NULL,
attempted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT NOW(),
finalized_at timestamptz,
scheduled_at timestamptz NOT NULL DEFAULT NOW(),
priority smallint NOT NULL DEFAULT 1,
args jsonb,
attempted_by text[],
errors jsonb[],
kind text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}',
queue text NOT NULL DEFAULT 'default',
tags varchar(255)[],
CONSTRAINT finalized_or_finalized_at_null CHECK (
(state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL
),
CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0),
CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4),
CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128),
CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128)
);
CREATE INDEX river_job_kind ON river.river_job USING btree(kind);
CREATE INDEX river_job_state_and_finalized_at_index ON river.river_job USING btree(state, finalized_at)
WHERE finalized_at IS NOT NULL;
CREATE INDEX river_job_prioritized_fetching_index ON river.river_job USING btree(
state,
queue,
priority,
scheduled_at,
id
);
CREATE INDEX river_job_args_index ON river.river_job USING GIN(args);
CREATE INDEX river_job_metadata_index ON river.river_job USING GIN(metadata);
CREATE OR REPLACE FUNCTION river.river_job_notify()
RETURNS TRIGGER
AS $$
DECLARE
payload json;
BEGIN
IF NEW.state = 'available' THEN
payload = json_build_object('queue', NEW.queue);
PERFORM pg_notify('river_insert', payload::text);
END IF;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;
CREATE TRIGGER river_notify
AFTER INSERT ON river.river_job
FOR EACH ROW
EXECUTE PROCEDURE river.river_job_notify();
CREATE UNLOGGED TABLE river.river_leader(
elected_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
leader_id text NOT NULL,
name text PRIMARY KEY,
CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128),
CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128)
);
-- River main migration 003 [up]
ALTER TABLE river.river_job ALTER COLUMN tags SET DEFAULT '{}';
UPDATE river.river_job SET tags = '{}' WHERE tags IS NULL;
ALTER TABLE river.river_job ALTER COLUMN tags SET NOT NULL;
-- River main migration 004 [up]
ALTER TABLE river.river_job ALTER COLUMN args SET DEFAULT '{}';
UPDATE river.river_job SET args = '{}' WHERE args IS NULL;
ALTER TABLE river.river_job ALTER COLUMN args SET NOT NULL;
ALTER TABLE river.river_job ALTER COLUMN args DROP DEFAULT;
ALTER TABLE river.river_job ALTER COLUMN metadata SET DEFAULT '{}';
UPDATE river.river_job SET metadata = '{}' WHERE metadata IS NULL;
ALTER TABLE river.river_job ALTER COLUMN metadata SET NOT NULL;
ALTER TYPE river.river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded';
ALTER TABLE river.river_job DROP CONSTRAINT finalized_or_finalized_at_null;
ALTER TABLE river.river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK (
(finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR
(finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded'))
);
DROP TRIGGER river_notify ON river.river_job;
DROP FUNCTION river.river_job_notify;
CREATE TABLE river.river_queue (
name text PRIMARY KEY NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
paused_at timestamptz,
updated_at timestamptz NOT NULL
);
ALTER TABLE river.river_leader
ALTER COLUMN name SET DEFAULT 'default',
DROP CONSTRAINT name_length,
ADD CONSTRAINT name_length CHECK (name = 'default');
-- River main migration 005 [up]
DO
$body$
BEGIN
IF (SELECT to_regclass('river.river_migration') IS NOT NULL) THEN
ALTER TABLE river.river_migration
RENAME TO river_migration_old;
CREATE TABLE river.river_migration(
line TEXT NOT NULL,
version bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128),
CONSTRAINT version_gte_1 CHECK (version >= 1),
PRIMARY KEY (line, version)
);
INSERT INTO river.river_migration (created_at, line, version)
SELECT created_at, 'main', version
FROM river.river_migration_old;
DROP TABLE river.river_migration_old;
END IF;
END;
$body$
LANGUAGE 'plpgsql';
ALTER TABLE river.river_job
ADD COLUMN IF NOT EXISTS unique_key bytea;
CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx
ON river.river_job (kind, unique_key)
WHERE unique_key IS NOT NULL;
CREATE UNLOGGED TABLE river.river_client (
id text PRIMARY KEY NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}',
paused_at timestamptz,
updated_at timestamptz NOT NULL,
CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128)
);
CREATE UNLOGGED TABLE river.river_client_queue (
river_client_id text NOT NULL REFERENCES river.river_client (id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
max_workers bigint NOT NULL DEFAULT 0,
metadata jsonb NOT NULL DEFAULT '{}',
num_jobs_completed bigint NOT NULL DEFAULT 0,
num_jobs_running bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL,
PRIMARY KEY (river_client_id, name),
CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128),
CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0),
CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0)
);
-- River main migration 006 [up]
CREATE OR REPLACE FUNCTION river.river_job_state_in_bitmask(bitmask BIT(8), state river.river_job_state)
RETURNS boolean
LANGUAGE SQL
IMMUTABLE
AS $$
SELECT CASE state
WHEN 'available' THEN get_bit(bitmask, 7)
WHEN 'cancelled' THEN get_bit(bitmask, 6)
WHEN 'completed' THEN get_bit(bitmask, 5)
WHEN 'discarded' THEN get_bit(bitmask, 4)
WHEN 'pending' THEN get_bit(bitmask, 3)
WHEN 'retryable' THEN get_bit(bitmask, 2)
WHEN 'running' THEN get_bit(bitmask, 1)
WHEN 'scheduled' THEN get_bit(bitmask, 0)
ELSE 0
END = 1;
$$;
ALTER TABLE river.river_job ADD COLUMN IF NOT EXISTS unique_states BIT(8);
CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON river.river_job (unique_key)
WHERE unique_key IS NOT NULL
AND unique_states IS NOT NULL
AND river.river_job_state_in_bitmask(unique_states, state);
DROP INDEX river.river_job_kind_unique_key_idx;
+27 -2
View File
@@ -353,8 +353,6 @@ model Survey {
creator User? @relation(fields: [createdBy], references: [id])
createdBy String?
status SurveyStatus @default(draft)
startsAt DateTime?
endsAt DateTime?
/// [SurveyWelcomeCard]
welcomeCard Json @default("{\"enabled\": false}")
/// [SurveyQuestions]
@@ -855,6 +853,32 @@ model Account {
@@index([userId])
}
/// Stores active authentication sessions for revocable server-side login state.
///
/// @property sessionToken - Opaque token stored in the browser cookie
/// @property user - The Formbricks user who owns this session
/// @property expires - Hard expiry for the session
model Session {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
sessionToken String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
expires DateTime
@@index([userId])
}
/// Stores one-time verification tokens used by Auth.js adapter flows.
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}
/// Represents a user in the Formbricks system.
/// Central model for user authentication and profile management.
///
@@ -880,6 +904,7 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
-2
View File
@@ -53,8 +53,6 @@ const ZSurveyBase = z.object({
redirectUrl: z.url().nullable().describe("The URL to redirect to after the survey is completed"),
type: z.enum(SurveyType).describe("The type of the survey"),
status: z.enum(SurveyStatus).describe("The status of the survey"),
startsAt: z.coerce.date().nullable().optional().describe("When the survey should start"),
endsAt: z.coerce.date().nullable().optional().describe("When the survey should end"),
thankYouMessage: z.string().nullable().describe("The thank you message of the survey"),
showLanguageSwitch: z.boolean().nullable().describe("Whether to show the language switch"),
showThankYouMessage: z.boolean().nullable().describe("Whether to show the thank you message"),
+85
View File
@@ -0,0 +1,85 @@
{
"common": {
"and": "ja",
"apply": "rakenda",
"auto_close_wrapper": "Automaatse sulgemise ümbris",
"back": "Tagasi",
"close_survey": "Sulge küsitlus",
"company_logo": "Ettevõtte logo",
"finish": "Lõpeta",
"language_switch": "Keele vahetamine",
"next": "Edasi",
"no_results_found": "Tulemusi ei leitud",
"open_in_new_tab": "Ava uuel vahelehel",
"people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}",
"please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.",
"powered_by": "Teenust pakub",
"privacy_policy": "Privaatsuspoliitika",
"protected_by_reCAPTCHA_and_the_Google": "Kaitstud reCAPTCHA ja Google'i poolt",
"question": "Küsimus",
"question_video": "Küsimuse video",
"required": "Kohustuslik",
"respondents_will_not_see_this_card": "Vastajad ei näe seda kaarti",
"retry": "Proovi uuesti",
"retrying": "Proovin uuesti…",
"search": "Otsi...",
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
"terms_of_service": "Teenusetingimused",
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
"all_options_must_be_ranked": "Palun järjesta kõik variandid",
"all_rows_must_be_answered": "Palun vasta kõikidele ridadele",
"file_extension_must_be": "Faililaiend peab olema {extension}",
"file_extension_must_not_be": "Faililaiend ei tohi olla {extension}",
"file_input": {
"duplicate_files": "Järgmised failid on juba üles laaditud: {duplicateNames}. Duplikaatfailid ei ole lubatud.",
"file_size_exceeded": "Järgmised failid ületavad maksimaalse suuruse {maxSizeInMB} MB ja eemaldati: {fileNames}",
"file_size_exceeded_alert": "Fail peab olema väiksem kui {maxSizeInMB} MB",
"no_valid_file_types_selected": "Ühtegi kehtivat failitüüpi pole valitud. Palun vali kehtiv failitüüp.",
"only_one_file_can_be_uploaded_at_a_time": "Korraga saab üles laadida ainult ühe faili.",
"placeholder_text": "Klõpsa või lohista failide üleslaadimiseks",
"upload_failed": "Üleslaadimine ebaõnnestus! Palun proovi uuesti.",
"uploading": "Üleslaadimine...",
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
},
"invalid_device_error": {
"message": "Palun keela küsitluse seadetes rämpsposti kaitse, et jätkata selle seadmega.",
"title": "See seade ei toeta rämpsposti kaitset."
},
"invalid_format": "Palun sisesta kehtiv vorming",
"is_between": "Palun vali kuupäev vahemikus {startDate} kuni {endDate}",
"is_earlier_than": "Palun vali kuupäev enne {date}",
"is_greater_than": "Palun sisesta väärtus, mis on suurem kui {min}",
"is_later_than": "Palun vali kuupäev pärast {date}",
"is_less_than": "Palun sisesta väärtus, mis on väiksem kui {max}",
"is_not_between": "Palun vali kuupäev, mis ei jää vahemikku {startDate} kuni {endDate}",
"max_length": "Palun sisesta mitte rohkem kui {max} tähemärki",
"max_selections": "Palun vali mitte rohkem kui {max} varianti",
"max_value": "Palun sisesta väärtus, mis ei ole suurem kui {max}",
"min_length": "Palun sisesta vähemalt {min} tähemärki",
"min_selections": "Palun vali vähemalt {min} varianti",
"min_value": "Palun sisesta väärtus vähemalt {min}",
"minimum_options_ranked": "Palun järjesta vähemalt {min} varianti",
"minimum_rows_answered": "Palun vasta vähemalt {min} reale",
"please_enter_a_valid_email_address": "Palun sisesta kehtiv e-posti aadress",
"please_enter_a_valid_phone_number": "Palun sisesta kehtiv telefoninumber",
"please_enter_a_valid_url": "Palun sisesta kehtiv URL",
"please_fill_out_this_field": "Palun täida see väli",
"recaptcha_error": {
"message": "Sinu vastust ei saanud esitada, kuna see märgiti automatiseeritud tegevuseks. Kui sa hingad, palun proovi uuesti.",
"title": "Me ei suutnud kinnitada, et sa oled inimene."
},
"value_must_contain": "Väärtus peab sisaldama {value}",
"value_must_equal": "Väärtus peab võrduma {value}",
"value_must_not_contain": "Väärtus ei tohi sisaldada {value}",
"value_must_not_equal": "Väärtus ei tohi võrduda {value}"
}
}
+3
View File
@@ -6,6 +6,7 @@ import daTranslations from "../../locales/da.json";
import deTranslations from "../../locales/de.json";
import enTranslations from "../../locales/en.json";
import esTranslations from "../../locales/es.json";
import etTranslations from "../../locales/et.json";
import frTranslations from "../../locales/fr.json";
import hiTranslations from "../../locales/hi.json";
import huTranslations from "../../locales/hu.json";
@@ -30,6 +31,7 @@ i18n
"de",
"en",
"es",
"et",
"fr",
"hi",
"hu",
@@ -50,6 +52,7 @@ i18n
de: { translation: deTranslations },
en: { translation: enTranslations },
es: { translation: esTranslations },
et: { translation: etTranslations },
fr: { translation: frTranslations },
hi: { translation: hiTranslations },
hu: { translation: huTranslations },
+5 -3
View File
@@ -1,11 +1,13 @@
import NextAuth from "next-auth";
import { type TUser } from "./user";
import NextAuth, { type DefaultSession } from "next-auth";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: { id: string };
user: DefaultSession["user"] & {
id: string;
isActive?: boolean;
};
}
}
+1 -11
View File
@@ -828,8 +828,6 @@ export const ZSurveyBase = z.object({
environmentId: z.string(),
createdBy: z.string().nullable(),
status: ZSurveyStatus,
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
displayOption: ZSurveyDisplayOption,
autoClose: z.number().nullable(),
triggers: z.array(z.object({ actionClass: ZActionClass })),
@@ -932,20 +930,12 @@ export const ZSurveyBase = z.object({
});
export const surveyRefinement = (survey: z.infer<typeof ZSurveyBase>, ctx: z.RefinementCtx): void => {
const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden, startsAt, endsAt } = survey;
const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden } = survey;
// Validate: must have questions OR blocks with elements, not both
const hasQuestions = questions.length > 0;
const hasBlocks = blocks.length > 0 && blocks.some((b) => b.elements.length > 0);
if (startsAt && endsAt && startsAt >= endsAt) {
ctx.addIssue({
code: "custom",
message: "Survey start date must be before end date",
path: ["startsAt"],
});
}
if (!hasQuestions && !hasBlocks) {
ctx.addIssue({
code: "custom",
+220 -2
View File
@@ -1,8 +1,30 @@
diff --git a/core/lib/assert.js b/core/lib/assert.js
--- a/core/lib/assert.js
+++ b/core/lib/assert.js
@@ -52,12 +52,6 @@
if (provider.type === "credentials") hasCredentials = true;else if (provider.type === "email") hasEmail = true;else if (provider.id === "twitter" && provider.version === "2.0") hasTwitterOAuth2 = true;
}
if (hasCredentials) {
- var _options$session;
- const dbStrategy = ((_options$session = options.session) === null || _options$session === void 0 ? void 0 : _options$session.strategy) === "database";
- const onlyCredentials = !options.providers.some(p => p.type !== "credentials");
- if (dbStrategy && onlyCredentials) {
- return new _errors.UnsupportedStrategy("Signin in with credentials only supported if JWT strategy is enabled");
- }
const credentialsNoAuthorize = options.providers.some(p => p.type === "credentials" && !p.authorize);
if (credentialsNoAuthorize) {
return new _errors.MissingAuthorize("Must define an authorize() handler to use credentials authentication provider");
@@ -80,4 +74,4 @@
warned = true;
}
return warnings;
-}
\ No newline at end of file
+}
diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..472772cfefc2c2947536d6a22b022c2f9c27c61f 100644
--- a/core/lib/oauth/client.js
+++ b/core/lib/oauth/client.js
@@ -5,9 +5,73 @@ Object.defineProperty(exports, "__esModule", {
@@ -5,9 +5,73 @@
});
exports.openidClient = openidClient;
var _openidClient = require("openid-client");
@@ -77,3 +99,199 @@ index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..472772cfefc2c2947536d6a22b022c2f
let issuer;
if (provider.wellKnown) {
issuer = await _openidClient.Issuer.discover(provider.wellKnown);
diff --git a/core/routes/callback.js b/core/routes/callback.js
--- a/core/routes/callback.js
+++ b/core/routes/callback.js
@@ -377,29 +377,48 @@
cookies
};
}
- const defaultToken = {
- name: user.name,
- email: user.email,
- picture: user.image,
- sub: (_user$id3 = user.id) === null || _user$id3 === void 0 ? void 0 : _user$id3.toString()
- };
- const token = await callbacks.jwt({
- token: defaultToken,
- user,
- account,
- isNewUser: false,
- trigger: "signIn"
- });
- const newToken = await jwt.encode({
- ...jwt,
- token
- });
- const cookieExpires = new Date();
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000);
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires
- });
- cookies.push(...sessionCookies);
+ if (useJwtSession) {
+ const defaultToken = {
+ name: user.name,
+ email: user.email,
+ picture: user.image,
+ sub: (_user$id3 = user.id) === null || _user$id3 === void 0 ? void 0 : _user$id3.toString()
+ };
+ const token = await callbacks.jwt({
+ token: defaultToken,
+ user,
+ account,
+ isNewUser: false,
+ trigger: "signIn"
+ });
+ const newToken = await jwt.encode({
+ ...jwt,
+ token
+ });
+ const cookieExpires = new Date();
+ cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000);
+ const sessionCookies = sessionStore.chunk(newToken, {
+ expires: cookieExpires
+ });
+ cookies.push(...sessionCookies);
+ } else {
+ if (!adapter) {
+ throw new Error("Missing adapter");
+ }
+ const session = await adapter.createSession({
+ sessionToken: await options.session.generateSessionToken(),
+ userId: user.id,
+ expires: (0, _utils.fromDate)(options.session.maxAge)
+ });
+ cookies.push({
+ name: options.cookies.sessionToken.name,
+ value: session.sessionToken,
+ options: {
+ ...options.cookies.sessionToken.options,
+ expires: session.expires
+ }
+ });
+ }
await ((_events$signIn3 = events.signIn) === null || _events$signIn3 === void 0 ? void 0 : _events$signIn3.call(events, {
user,
account
@@ -414,4 +433,4 @@
body: `Error: Callback for provider type ${provider.type} not supported`,
cookies
};
-}
\ No newline at end of file
+}
diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts
--- a/src/core/lib/assert.ts
+++ b/src/core/lib/assert.ts
@@ -101,16 +101,6 @@
}
if (hasCredentials) {
- const dbStrategy = options.session?.strategy === "database"
- const onlyCredentials = !options.providers.some(
- (p) => p.type !== "credentials"
- )
- if (dbStrategy && onlyCredentials) {
- return new UnsupportedStrategy(
- "Signin in with credentials only supported if JWT strategy is enabled"
- )
- }
-
const credentialsNoAuthorize = options.providers.some(
(p) => p.type === "credentials" && !p.authorize
)
diff --git a/src/core/routes/callback.ts b/src/core/routes/callback.ts
--- a/src/core/routes/callback.ts
+++ b/src/core/routes/callback.ts
@@ -1,6 +1,6 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
-import { hashToken } from "../lib/utils"
+import { fromDate, hashToken } from "../lib/utils"
import getAdapterUserFromEmail from "../lib/email/getUserFromEmail"
import type { InternalOptions } from "../types"
@@ -385,37 +385,58 @@
)}`,
cookies,
}
- }
-
- const defaultToken = {
- name: user.name,
- email: user.email,
- picture: user.image,
- sub: user.id?.toString(),
}
- const token = await callbacks.jwt({
- token: defaultToken,
- user,
- // @ts-expect-error
- account,
- isNewUser: false,
- trigger: "signIn",
- })
+ if (useJwtSession) {
+ const defaultToken = {
+ name: user.name,
+ email: user.email,
+ picture: user.image,
+ sub: user.id?.toString(),
+ }
- // Encode token
- const newToken = await jwt.encode({ ...jwt, token })
+ const token = await callbacks.jwt({
+ token: defaultToken,
+ user,
+ // @ts-expect-error
+ account,
+ isNewUser: false,
+ trigger: "signIn",
+ })
- // Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
+ // Encode token
+ const newToken = await jwt.encode({ ...jwt, token })
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
- })
+ // Set cookie expiry date
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
- cookies.push(...sessionCookies)
+ const sessionCookies = sessionStore.chunk(newToken, {
+ expires: cookieExpires,
+ })
+ cookies.push(...sessionCookies)
+ } else {
+ if (!adapter) {
+ throw new Error("Missing adapter")
+ }
+
+ const session = await adapter.createSession({
+ sessionToken: await options.session.generateSessionToken(),
+ userId: user.id,
+ expires: fromDate(options.session.maxAge),
+ })
+
+ cookies.push({
+ name: options.cookies.sessionToken.name,
+ value: (session as AdapterSession).sessionToken,
+ options: {
+ ...options.cookies.sessionToken.options,
+ expires: (session as AdapterSession).expires,
+ },
+ })
+ }
+
// @ts-expect-error
await events.signIn?.({ user, account })
+15858 -9447
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,7 +1,6 @@
packages:
- "apps/*"
- "packages/*"
- "services/*"
# Allow lifecycle scripts for packages that need to build native binaries
# Required for pnpm v10+ which blocks scripts by default
@@ -1,51 +0,0 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/formbricks/formbricks/services/river-poc-worker/internal/config"
"github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := config.LoadFromEnv()
if err != nil {
logger.Error("failed to load configuration", slog.Any("error", err))
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
app, err := riverapp.New(ctx, cfg, logger)
if err != nil {
logger.Error("failed to initialize River application", slog.Any("error", err))
os.Exit(1)
}
if err := app.Start(ctx); err != nil {
logger.Error("failed to start River application", slog.Any("error", err))
os.Exit(1)
}
logger.Info("river-poc-worker started")
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := app.Stop(shutdownCtx); err != nil {
logger.Error("failed to stop River application cleanly", slog.Any("error", err))
os.Exit(1)
}
logger.Info("river-poc-worker stopped")
}
-38
View File
@@ -1,38 +0,0 @@
mode: atomic
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:30.36,32.23 2 2
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:32.23,34.3 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:36.2,39.8 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:42.53,44.42 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:44.42,46.3 1 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:48.2,49.51 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:49.51,51.3 1 6
github.com/formbricks/formbricks/services/river-poc-worker/internal/config/config.go:53.2,54.27 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:26.38,26.64 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:32.36,32.60 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:44.67,46.2 1 2
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:48.63,50.2 1 2
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:52.67,55.2 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:57.94,60.2 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:62.90,65.2 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/workers/survey_lifecycle.go:73.3,82.2 1 2
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:15.13,19.16 3 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:19.16,22.3 2 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:24.2,28.16 4 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:28.16,31.3 2 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:33.2,33.39 1 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:33.39,36.3 2 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:38.2,45.46 5 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:45.46,48.3 2 0
github.com/formbricks/formbricks/services/river-poc-worker/cmd/river-poc-worker/main.go:50.2,50.41 1 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:22.85,24.2 1 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:31.17,33.16 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:33.16,35.3 1 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:37.2,51.16 4 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:51.16,54.3 2 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:56.2,59.8 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:62.48,63.44 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:63.44,65.3 1 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:67.2,67.12 1 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:70.47,73.43 2 1
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:73.43,75.3 1 0
github.com/formbricks/formbricks/services/river-poc-worker/internal/riverapp/app.go:77.2,77.12 1 1
-29
View File
@@ -1,29 +0,0 @@
module github.com/formbricks/formbricks/services/river-poc-worker
go 1.25.1
require (
github.com/jackc/pgx/v5 v5.9.1
github.com/riverqueue/river v0.32.0
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.32.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/riverqueue/river/riverdriver v0.32.0 // indirect
github.com/riverqueue/river/rivershared v0.32.0 // indirect
github.com/riverqueue/river/rivertype v0.32.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.uber.org/goleak v1.3.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-61
View File
@@ -1,61 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/riverqueue/river v0.32.0 h1:j15EoFZ4oQWXcCq8NyzWwoi3fdaO8mECTB100NSv9Qw=
github.com/riverqueue/river v0.32.0/go.mod h1:zABAdLze3HI7K02N+veikXyK5FjiLzjimnQpZ1Duyng=
github.com/riverqueue/river/riverdriver v0.32.0 h1:AG6a2hNVOIGLx/+3IRtbwofJRYEI7xqnVVxULe9s4Lg=
github.com/riverqueue/river/riverdriver v0.32.0/go.mod h1:FRDMuqnLOsakeJOHlozKK+VH7W7NLp+6EToxQ2JAjBE=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.32.0 h1:CqrRxxcdA/0sHkxLNldsQff9DIG5qxn2EJO09Pau3w0=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.32.0/go.mod h1:j45UPpbMpcI10m+huTeNUaOwzoLJcEg0K6ihWXWeOec=
github.com/riverqueue/river/rivershared v0.32.0 h1:7DwdrppMU9uoU2iU9aGQiv91nBezjlcI85NV4PmnLHw=
github.com/riverqueue/river/rivershared v0.32.0/go.mod h1:UE7GEj3zaTV3cKw7Q3angCozlNEGsL50xZBKJQ9m6zU=
github.com/riverqueue/river/rivertype v0.32.0 h1:RW7uodfl86gYkjwDponTAPNnUqM+X6BjlsNHxbt6Ztg=
github.com/riverqueue/river/rivertype v0.32.0/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1,55 +0,0 @@
package config
import (
"errors"
"net/url"
"os"
"strings"
)
const (
DefaultRiverSchema = "river"
)
var ErrDatabaseURLRequired = errors.New("DATABASE_URL is required")
var prismaUnsupportedQueryParams = []string{
"connection_limit",
"pgbouncer",
"pool_timeout",
"schema",
"socket_timeout",
"statement_cache_size",
}
type Config struct {
DatabaseURL string
RiverSchema string
}
func LoadFromEnv() (Config, error) {
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if databaseURL == "" {
return Config{}, ErrDatabaseURLRequired
}
return Config{
DatabaseURL: sanitizeDatabaseURL(databaseURL),
RiverSchema: DefaultRiverSchema,
}, nil
}
func sanitizeDatabaseURL(databaseURL string) string {
parsedURL, err := url.Parse(databaseURL)
if err != nil || parsedURL.Scheme == "" {
return databaseURL
}
query := parsedURL.Query()
for _, key := range prismaUnsupportedQueryParams {
query.Del(key)
}
parsedURL.RawQuery = query.Encode()
return parsedURL.String()
}
@@ -1,34 +0,0 @@
package config
import "testing"
func TestLoadFromEnv(t *testing.T) {
t.Run("returns an error when DATABASE_URL is missing", func(t *testing.T) {
t.Setenv("DATABASE_URL", "")
_, err := LoadFromEnv()
if err != ErrDatabaseURLRequired {
t.Fatalf("expected ErrDatabaseURLRequired, got %v", err)
}
})
t.Run("loads database configuration from environment", func(t *testing.T) {
t.Setenv(
"DATABASE_URL",
" postgres://formbricks:password@localhost:5432/formbricks?connection_limit=5&schema=formbricks&sslmode=disable ",
)
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got, want := cfg.DatabaseURL, "postgres://formbricks:password@localhost:5432/formbricks?sslmode=disable"; got != want {
t.Fatalf("expected DatabaseURL %q, got %q", want, got)
}
if got, want := cfg.RiverSchema, DefaultRiverSchema; got != want {
t.Fatalf("expected RiverSchema %q, got %q", want, got)
}
})
}
@@ -1,78 +0,0 @@
package riverapp
import (
"context"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/riverqueue/river"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/formbricks/formbricks/services/river-poc-worker/internal/config"
"github.com/formbricks/formbricks/services/river-poc-worker/internal/workers"
)
type App struct {
client *river.Client[pgx.Tx]
pool *pgxpool.Pool
}
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
return newWithOptions(ctx, cfg, logger, false)
}
func newWithOptions(
ctx context.Context,
cfg config.Config,
logger *slog.Logger,
testOnly bool,
) (*App, error) {
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("create pgx pool: %w", err)
}
workerRegistry := river.NewWorkers()
workers.Register(workerRegistry, logger)
client, err := river.NewClient(riverpgxv5.New(pool), &river.Config{
Logger: logger,
Schema: cfg.RiverSchema,
Queues: map[string]river.QueueConfig{
workers.QueueName: {
MaxWorkers: 2,
},
},
TestOnly: testOnly,
Workers: workerRegistry,
})
if err != nil {
pool.Close()
return nil, fmt.Errorf("create river client: %w", err)
}
return &App{
client: client,
pool: pool,
}, nil
}
func (a *App) Start(ctx context.Context) error {
if err := a.client.Start(ctx); err != nil {
return fmt.Errorf("start river client: %w", err)
}
return nil
}
func (a *App) Stop(ctx context.Context) error {
defer a.pool.Close()
if err := a.client.Stop(ctx); err != nil {
return fmt.Errorf("stop river client: %w", err)
}
return nil
}
@@ -1,139 +0,0 @@
package riverapp
import (
"bytes"
"context"
"fmt"
"log/slog"
"net"
"net/url"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivermigrate"
"github.com/formbricks/formbricks/services/river-poc-worker/internal/config"
"github.com/formbricks/formbricks/services/river-poc-worker/internal/workers"
)
func TestAppProcessesSurveyLifecycleJobsFromSQL(t *testing.T) {
cfg, err := config.LoadFromEnv()
if err != nil {
t.Skip("DATABASE_URL is required for integration tests")
}
if !canReachPostgres(t, cfg.DatabaseURL) {
t.Skip("Postgres is not reachable for integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
t.Fatalf("create pgx pool: %v", err)
}
defer pool.Close()
schema := fmt.Sprintf("river_poc_test_%d", time.Now().UnixNano())
if _, err := pool.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA "%s"`, schema)); err != nil {
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
dropCtx, dropCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer dropCancel()
_, _ = pool.Exec(dropCtx, fmt.Sprintf(`DROP SCHEMA IF EXISTS "%s" CASCADE`, schema))
})
migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Schema: schema})
if err != nil {
t.Fatalf("create migrator: %v", err)
}
if _, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil {
t.Fatalf("migrate river schema: %v", err)
}
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
app, err := newWithOptions(ctx, config.Config{
DatabaseURL: cfg.DatabaseURL,
RiverSchema: schema,
}, logger, true)
if err != nil {
t.Fatalf("create app: %v", err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer stopCancel()
_ = app.Stop(stopCtx)
}()
if err := app.Start(ctx); err != nil {
t.Fatalf("start app: %v", err)
}
payload := `{"surveyId":"survey_1","environmentId":"env_1","scheduledFor":"2026-04-01T12:00:00.000Z"}`
insertQuery := fmt.Sprintf(`
INSERT INTO "%s"."river_job" (args, kind, max_attempts, queue)
VALUES ($1::jsonb, $2, $3, $4)
`, schema)
if _, err := pool.Exec(ctx, insertQuery, payload, workers.SurveyStartKind, 3, workers.QueueName); err != nil {
t.Fatalf("insert job: %v", err)
}
if _, err := pool.Exec(ctx, `SELECT pg_notify($1, $2)`, fmt.Sprintf(`%s.river_insert`, schema), `{"queue":"survey_lifecycle"}`); err != nil {
t.Fatalf("notify river queue: %v", err)
}
deadline := time.Now().Add(10 * time.Second)
for {
output := logs.String()
if strings.Contains(output, "STARTING SURVEY") {
if !strings.Contains(output, "job_kind="+workers.SurveyStartKind) {
t.Fatalf("expected log output to contain %q, got %q", workers.SurveyStartKind, output)
}
if !strings.Contains(output, "survey_id=survey_1") {
t.Fatalf("expected log output to contain survey_id, got %q", output)
}
if !strings.Contains(output, "environment_id=env_1") {
t.Fatalf("expected log output to contain environment_id, got %q", output)
}
if !strings.Contains(output, "scheduled_for=2026-04-01T12:00:00.000Z") {
t.Fatalf("expected log output to contain scheduled_for, got %q", output)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for lifecycle job to be processed; logs=%q", output)
}
time.Sleep(100 * time.Millisecond)
}
}
func canReachPostgres(t *testing.T, databaseURL string) bool {
t.Helper()
parsedURL, err := url.Parse(databaseURL)
if err != nil {
return false
}
port := parsedURL.Port()
if port == "" {
port = "5432"
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(parsedURL.Hostname(), port), 500*time.Millisecond)
if err != nil {
return false
}
defer conn.Close()
return true
}
@@ -1,82 +0,0 @@
package workers
import (
"context"
"log/slog"
"github.com/riverqueue/river"
)
const (
QueueName = "survey_lifecycle"
SurveyStartKind = "survey.start"
SurveyEndKind = "survey.end"
)
type SurveyLifecyclePayload struct {
SurveyID string `json:"surveyId"`
EnvironmentID string `json:"environmentId"`
ScheduledFor string `json:"scheduledFor"`
}
type SurveyStartArgs struct {
SurveyLifecyclePayload
}
func (SurveyStartArgs) Kind() string { return SurveyStartKind }
type SurveyEndArgs struct {
SurveyLifecyclePayload
}
func (SurveyEndArgs) Kind() string { return SurveyEndKind }
type SurveyStartWorker struct {
river.WorkerDefaults[SurveyStartArgs]
logger *slog.Logger
}
type SurveyEndWorker struct {
river.WorkerDefaults[SurveyEndArgs]
logger *slog.Logger
}
func NewSurveyStartWorker(logger *slog.Logger) *SurveyStartWorker {
return &SurveyStartWorker{logger: logger}
}
func NewSurveyEndWorker(logger *slog.Logger) *SurveyEndWorker {
return &SurveyEndWorker{logger: logger}
}
func Register(workerRegistry *river.Workers, logger *slog.Logger) {
river.AddWorker(workerRegistry, NewSurveyStartWorker(logger))
river.AddWorker(workerRegistry, NewSurveyEndWorker(logger))
}
func (w *SurveyStartWorker) Work(ctx context.Context, job *river.Job[SurveyStartArgs]) error {
logLifecycle(ctx, w.logger, "STARTING SURVEY", SurveyStartKind, job.Args.SurveyLifecyclePayload)
return nil
}
func (w *SurveyEndWorker) Work(ctx context.Context, job *river.Job[SurveyEndArgs]) error {
logLifecycle(ctx, w.logger, "ENDING SURVEY", SurveyEndKind, job.Args.SurveyLifecyclePayload)
return nil
}
func logLifecycle(
ctx context.Context,
logger *slog.Logger,
message string,
kind string,
payload SurveyLifecyclePayload,
) {
logger.InfoContext(
ctx,
message,
slog.String("job_kind", kind),
slog.String("survey_id", payload.SurveyID),
slog.String("environment_id", payload.EnvironmentID),
slog.String("scheduled_for", payload.ScheduledFor),
)
}
@@ -1,107 +0,0 @@
package workers
import (
"bytes"
"context"
"log/slog"
"reflect"
"strings"
"testing"
"github.com/riverqueue/river"
)
func TestRegister(t *testing.T) {
workerRegistry := river.NewWorkers()
Register(workerRegistry, slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)))
workersMap := reflect.ValueOf(workerRegistry).Elem().FieldByName("workersMap")
if !workersMap.IsValid() {
t.Fatal("expected workers registry to expose a workersMap field")
}
if !workersMap.MapIndex(reflect.ValueOf(SurveyStartKind)).IsValid() {
t.Fatalf("expected %q worker to be registered", SurveyStartKind)
}
if !workersMap.MapIndex(reflect.ValueOf(SurveyEndKind)).IsValid() {
t.Fatalf("expected %q worker to be registered", SurveyEndKind)
}
}
func TestSurveyLifecycleWorkers(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
work func(context.Context, *slog.Logger) error
expectedMessage string
expectedKind string
}{
{
name: "start worker logs structured survey start event",
work: func(ctx context.Context, logger *slog.Logger) error {
return NewSurveyStartWorker(logger).Work(ctx, &river.Job[SurveyStartArgs]{
Args: SurveyStartArgs{
SurveyLifecyclePayload: SurveyLifecyclePayload{
SurveyID: "survey_start_1",
EnvironmentID: "env_1",
ScheduledFor: "2026-04-01T12:00:00.000Z",
},
},
})
},
expectedMessage: "STARTING SURVEY",
expectedKind: SurveyStartKind,
},
{
name: "end worker logs structured survey end event",
work: func(ctx context.Context, logger *slog.Logger) error {
return NewSurveyEndWorker(logger).Work(ctx, &river.Job[SurveyEndArgs]{
Args: SurveyEndArgs{
SurveyLifecyclePayload: SurveyLifecyclePayload{
SurveyID: "survey_end_1",
EnvironmentID: "env_2",
ScheduledFor: "2026-04-02T12:00:00.000Z",
},
},
})
},
expectedMessage: "ENDING SURVEY",
expectedKind: SurveyEndKind,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
if err := tt.work(context.Background(), logger); err != nil {
t.Fatalf("expected no error, got %v", err)
}
output := logs.String()
if !strings.Contains(output, tt.expectedMessage) {
t.Fatalf("expected log output to contain %q, got %q", tt.expectedMessage, output)
}
if !strings.Contains(output, "job_kind="+tt.expectedKind) {
t.Fatalf("expected log output to contain kind %q, got %q", tt.expectedKind, output)
}
if !strings.Contains(output, "survey_id=") {
t.Fatalf("expected log output to contain survey_id, got %q", output)
}
if !strings.Contains(output, "environment_id=") {
t.Fatalf("expected log output to contain environment_id, got %q", output)
}
if !strings.Contains(output, "scheduled_for=") {
t.Fatalf("expected log output to contain scheduled_for, got %q", output)
}
})
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@formbricks/river-poc-worker",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.32.1",
"scripts": {
"go": "dotenv -e ../../.env -- go run ./cmd/river-poc-worker",
"test": "dotenv -e ../../.env -- go test ./...",
"test:coverage": "dotenv -e ../../.env -- go test ./... -covermode=atomic -coverprofile=coverage.out"
},
"devDependencies": {
"dotenv-cli": "11.0.0"
}
}
+1
View File
@@ -141,6 +141,7 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",
+15
View File
@@ -0,0 +1,15 @@
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",
"name": "minimatch",
"description": "a glob matcher in javascript",
"version": "3.1.5",
"repository": {
"type": "git",
"url": "git://github.com/isaacs/minimatch.git"
},
"main": "minimatch.js",
"scripts": {
"test": "tap",
"preversion": "npm test"
},
"engines": {
"node": "*"
},
"dependencies": {
"brace-expansion": "5.0.5"
},
"devDependencies": {
"tap": "^15.1.6"
},
"license": "ISC",
"files": [
"minimatch.js"
]
}