Compare commits

...

3 Commits

22 changed files with 627 additions and 55 deletions

View File

@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;

View File

@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {

View File

@@ -217,7 +217,7 @@ describe("utils", () => {
});
describe("logApiError", () => {
test("logs API error details", () => {
test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -238,9 +238,11 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
});
@@ -275,6 +277,8 @@ describe("utils", () => {
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "",
method: "GET",
path: "/api/test",
error,
});
@@ -285,7 +289,7 @@ describe("utils", () => {
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set", () => {
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -295,11 +299,23 @@ describe("utils", () => {
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -309,20 +325,60 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
mockRequest.headers.set("x-request-id", "456");
const error: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
});

View File

@@ -6,13 +6,18 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID as a tag for easy filtering
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
@@ -24,6 +29,8 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");

View File

@@ -97,14 +97,13 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
);
const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId,
data: ZSegmentUpdateInput,
});
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,

View File

@@ -75,7 +75,6 @@ export function SegmentSettings({
try {
setIsUpdatingSegment(true);
const data = await updateSegmentAction({
environmentId,
segmentId: segment.id,
data: {
title: segment.title,

View File

@@ -124,7 +124,7 @@ export function TargetingCard({
};
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
const updatedSegment = await updateSegmentAction({ segmentId, data });
return updatedSegment?.data as TSegment;
};
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
const result = await updateSegmentAction({ segmentId: segment.id, data });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;

View File

@@ -21,7 +21,6 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZDeleteQuotaAction = z.object({
quotaId: ZId,
surveyId: ZId,
});
const checkQuotasEnabled = async (organizationId: string) => {
@@ -37,7 +36,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -49,7 +48,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],
@@ -72,7 +71,7 @@ const ZUpdateQuotaAction = z.object({
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -84,7 +83,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],

View File

@@ -85,7 +85,6 @@ export const QuotasCard = ({
setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId,
surveyId: localSurvey.id,
});
if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));

View File

@@ -10,6 +10,7 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
@@ -31,7 +32,6 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
const ZUpdateInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
data: ZInviteUpdateInput,
});
@@ -39,17 +39,16 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
data: parsedInput.data,
@@ -68,9 +67,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
throw new OperationNotAllowedError("Managers can only invite members");
}
await checkRoleManagementPermission(parsedInput.organizationId);
await checkRoleManagementPermission(organizationId);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };

View File

@@ -65,7 +65,7 @@ export function EditMembershipRole({
}
if (inviteId) {
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
await updateInviteAction({ inviteId: inviteId, data: { role } });
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));

View File

@@ -27,14 +27,15 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -42,7 +43,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
return await deleteInvite(parsedInput.inviteId);

View File

@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) {
// This is an invite
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
const result = await deleteInviteAction({ inviteId: invite?.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);

View File

@@ -38,6 +38,7 @@ export const ActionSettingsTab = ({
setOpen,
isReadOnly,
}: ActionSettingsTabProps) => {
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
const router = useRouter();
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
@@ -146,7 +147,7 @@ export const ActionSettingsTab = ({
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{!isReadOnly ? (
{isReadOnly ? null : (
<Button
type="button"
variant="destructive"
@@ -155,22 +156,22 @@ export const ActionSettingsTab = ({
<TrashIcon />
{t("common.delete")}
</Button>
) : null}
)}
<Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
<Link href={actionDocsHref} target="_blank">
{t("common.read_docs")}
</Link>
</Button>
</div>
{!isReadOnly ? (
{isReadOnly ? null : (
<div className="flex space-x-2">
<Button type="submit" loading={isUpdatingAction}>
{t("common.save_changes")}
</Button>
</div>
) : null}
)}
</div>
</form>
</FormProvider>

View File

@@ -4,7 +4,6 @@
import "@testing-library/jest-dom/vitest";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import {
createActionClassZodResolver,
@@ -45,9 +44,9 @@ const mockT = vi.fn((key: string, params?: any) => {
// Helper to create mock context
const createMockContext = () => {
const issues: z.ZodIssue[] = [];
const issues: unknown[] = [];
return {
addIssue: vi.fn((issue: z.ZodIssue) => issues.push(issue)),
addIssue: vi.fn((issue: unknown) => issues.push(issue)),
issues,
} as any;
};

View File

@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
formbricks.track(&quot;{watch("key")}&quot;)
</span>{" "}
{t("environments.actions.in_your_code_read_more_in_our")}{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
target="_blank"
rel="noreferrer"
className="underline">
{t("common.docs")}
</a>
{"."}

View File

@@ -43,6 +43,17 @@ vi.mock("@/lib/common/utils", () => ({
handleHiddenFields: vi.fn(),
}));
const mockUpdateQueue = {
hasPendingWork: vi.fn().mockReturnValue(false),
waitForPendingWork: vi.fn().mockResolvedValue(true),
};
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => mockUpdateQueue),
},
}));
describe("widget-file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
@@ -249,4 +260,265 @@ describe("widget-file", () => {
widget.removeWidgetContainer();
expect(document.getElementById("formbricks-container")).toBeFalsy();
});
test("renderWidget waits for pending identification before rendering", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
);
vi.useRealTimers();
});
test("renderWidget does not wait when no identification is pending", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
test("renderWidget reads contactId after identification wait completes", async () => {
let callCount = 0;
const mockConfigValue = {
get: vi.fn().mockImplementation(() => {
callCount++;
return {
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
// Simulate contactId becoming available after identification
userId: "user_abc",
contactId: callCount > 2 ? "contact_after_identification" : undefined,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
};
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
);
vi.useRealTimers();
});
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: null,
contactId: null,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: undefined,
} as unknown as TEnvironmentStateSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed but survey has no segment filters. Proceeding."
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});

View File

@@ -11,6 +11,7 @@ import {
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
@@ -60,6 +61,24 @@ export const renderWidget = async (
setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
}

View File

@@ -169,4 +169,104 @@ describe("UpdateQueue", () => {
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
);
});
test("hasPendingWork returns false when no updates and no flush in flight", () => {
expect(updateQueue.hasPendingWork()).toBe(false);
});
test("hasPendingWork returns true when updates are queued", () => {
updateQueue.updateUserId(mockUserId1);
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// Start processing but don't await — the debounce means the flush is in-flight
void updateQueue.processUpdates();
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("waitForPendingWork returns true immediately when no pending work", async () => {
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
});
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
expect(updateQueue.hasPendingWork()).toBe(false);
expect(sendUpdates).toHaveBeenCalled();
});
test("waitForPendingWork returns false when processUpdates rejects", async () => {
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
updateQueue.updateUserId(mockUserId1);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
const processPromise = updateQueue.processUpdates().catch(() => {});
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(false);
await processPromise;
});
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
vi.useFakeTimers();
// sendUpdates returns a promise that never resolves, simulating a network hang
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const resultPromise = updateQueue.waitForPendingWork();
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
await vi.advanceTimersByTimeAsync(500);
// Advance past the pending work timeout (5000ms)
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toBe(false);
vi.useRealTimers();
});
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// First call creates the flush promise
const firstPromise = updateQueue.processUpdates();
// Second call while first is still pending should not create a new flush
updateQueue.updateAttributes({ name: mockAttributes.name });
const secondPromise = updateQueue.processUpdates();
// Both promises should resolve (second is not orphaned)
await Promise.all([firstPromise, secondPromise]);
expect(updateQueue.hasPendingWork()).toBe(false);
});
});

View File

@@ -8,7 +8,9 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {}
@@ -63,17 +65,45 @@ export class UpdateQueue {
return !this.updates;
}
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> {
const logger = Logger.getInstance();
if (!this.updates) {
return;
}
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
return new Promise((resolve, reject) => {
const flushPromise = new Promise<void>((resolve, reject) => {
const handler = async (): Promise<void> => {
try {
let currentUpdates = { ...this.updates };
@@ -147,8 +177,10 @@ export class UpdateQueue {
}
this.clearUpdates();
this.pendingFlush = null;
resolve();
} catch (error: unknown) {
this.pendingFlush = null;
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
@@ -158,5 +190,8 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
});
this.pendingFlush = flushPromise;
return flushPromise;
}
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { ZActionClassNoCodeConfig, isValidActionClassUrlFilterValue } from "./action-classes";
describe("isValidActionClassUrlFilterValue", () => {
test("accepts valid absolute URLs for URL-like rules", () => {
expect(isValidActionClassUrlFilterValue("https://example.com/dashboard", "exactMatch")).toBe(true);
});
test("rejects hostnames without a valid TLD", () => {
expect(isValidActionClassUrlFilterValue("https://ok", "exactMatch")).toBe(false);
expect(isValidActionClassUrlFilterValue("https://ok.", "exactMatch")).toBe(false);
});
test("accepts localhost and root-relative paths", () => {
expect(isValidActionClassUrlFilterValue("http://localhost:3000/dashboard", "exactMatch")).toBe(true);
expect(isValidActionClassUrlFilterValue("/dashboard", "startsWith")).toBe(true);
});
test("skips URL-shape validation for free-form rules", () => {
expect(isValidActionClassUrlFilterValue("dashboard", "contains")).toBe(true);
});
});
describe("ZActionClassNoCodeConfig", () => {
test("rejects invalid URL-like filters", () => {
const result = ZActionClassNoCodeConfig.safeParse({
type: "pageView",
urlFilters: [{ rule: "exactMatch", value: "https://ok." }],
});
expect(result.success).toBe(false);
});
test("accepts valid URL-like filters", () => {
const result = ZActionClassNoCodeConfig.safeParse({
type: "pageView",
urlFilters: [{ rule: "exactMatch", value: "https://example.com/dashboard" }],
});
expect(result.success).toBe(true);
});
});

View File

@@ -26,16 +26,56 @@ export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;
const URL_LIKE_FILTER_RULES = new Set<TActionClassPageUrlRule>(["exactMatch", "startsWith", "notMatch"]);
const DOMAIN_HOSTNAME_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const isValidAbsoluteUrlFilterValue = (value: string): boolean => {
try {
const parsedUrl = new URL(value);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return false;
}
const isIPv6 = parsedUrl.hostname.startsWith("[") && parsedUrl.hostname.endsWith("]");
const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsedUrl.hostname);
return (
DOMAIN_HOSTNAME_REGEX.test(parsedUrl.hostname) || parsedUrl.hostname === "localhost" || isIPv6 || isIPv4
);
} catch {
return false;
}
};
export const isValidActionClassUrlFilterValue = (value: string, rule: TActionClassPageUrlRule): boolean => {
if (!URL_LIKE_FILTER_RULES.has(rule)) {
return true;
}
return value.startsWith("/") || isValidAbsoluteUrlFilterValue(value);
};
const ZActionClassUrlFilter = z
.object({
value: z.string().trim().min(1, {
error: "Value must contain atleast 1 character",
}),
rule: ZActionClassPageUrlRule,
})
.superRefine((data, ctx) => {
if (!isValidActionClassUrlFilterValue(data.value, data.rule)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Please enter a valid URL (e.g., https://example.com)",
});
}
});
const ZActionClassNoCodeConfigBase = z.object({
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll", "pageDwell"]),
urlFilters: z.array(
z.object({
value: z.string().trim().min(1, {
error: "Value must contain atleast 1 character",
}),
rule: ZActionClassPageUrlRule,
})
),
urlFilters: z.array(ZActionClassUrlFilter),
urlFiltersConnector: z.enum(["or", "and"]).optional(),
});