mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 122de0121e | |||
| eb416d7ff7 | |||
| 01f765e969 | |||
| 9366960f18 | |||
| 697dc9cc99 | |||
| 83bc272ed2 | |||
| 59cc9c564e |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
-213
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Vendored
+5
-3
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
Generated
+15858
-9447
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@
|
||||
"BREVO_API_KEY",
|
||||
"BREVO_LIST_ID",
|
||||
"CRON_SECRET",
|
||||
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
|
||||
"DATABASE_URL",
|
||||
"DEBUG",
|
||||
"E2E_TESTING",
|
||||
|
||||
Vendored
+15
@@ -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.
|
||||
Vendored
+1008
File diff suppressed because it is too large
Load Diff
Vendored
+28
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user