mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-20 09:20:49 -05:00
Compare commits
6 Commits
cursor/zp-
...
4.8.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac7369b3c | ||
|
|
913ab98d62 | ||
|
|
717a172ce0 | ||
|
|
8c935f20c2 | ||
|
|
a10404ba1d | ||
|
|
39788ce0e1 |
@@ -64,17 +64,15 @@ 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,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { project } = useEnvironment();
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type Instrumentation } from "next";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
const [error] = args;
|
||||
|
||||
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
|
||||
// These are handled gracefully in the UI and don't need server-side Sentry reporting
|
||||
if (error instanceof Error && isExpectedError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details with method and path", () => {
|
||||
test("logs API error details", () => {
|
||||
// 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/v2/management/surveys", { method: "POST" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -238,11 +238,9 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "POST",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -277,8 +275,6 @@ describe("utils", () => {
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "",
|
||||
method: "GET",
|
||||
path: "/api/test",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -289,7 +285,7 @@ describe("utils", () => {
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
|
||||
test("log API error details with SENTRY_DSN set", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -299,23 +295,11 @@ 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/v2/management/surveys", { method: "DELETE" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -325,60 +309,20 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,13 @@ 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 and request context as tags for easy filtering
|
||||
// Use Sentry scope to add correlation ID as a tag 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);
|
||||
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
method,
|
||||
path,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
|
||||
@@ -97,13 +97,14 @@ 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 getOrganizationIdFromSegmentId(parsedInput.segmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
|
||||
@@ -75,6 +75,7 @@ export function SegmentSettings({
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
const data = await updateSegmentAction({
|
||||
environmentId,
|
||||
segmentId: segment.id,
|
||||
data: {
|
||||
title: segment.title,
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TargetingCard({
|
||||
};
|
||||
|
||||
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, data });
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, 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, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZDeleteQuotaAction = z.object({
|
||||
quotaId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
const checkQuotasEnabled = async (organizationId: string) => {
|
||||
@@ -36,7 +37,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
|
||||
|
||||
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
|
||||
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -48,7 +49,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -71,7 +72,7 @@ const ZUpdateQuotaAction = z.object({
|
||||
|
||||
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
|
||||
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -83,7 +84,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,6 +85,7 @@ 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"));
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -32,6 +31,7 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
|
||||
|
||||
const ZUpdateInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
data: ZInviteUpdateInput,
|
||||
});
|
||||
|
||||
@@ -39,16 +39,17 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
|
||||
|
||||
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
|
||||
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
ctx.user.id,
|
||||
parsedInput.organizationId
|
||||
);
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
@@ -67,9 +68,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
|
||||
throw new OperationNotAllowedError("Managers can only invite members");
|
||||
}
|
||||
|
||||
await checkRoleManagementPermission(organizationId);
|
||||
await checkRoleManagementPermission(parsedInput.organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function EditMembershipRole({
|
||||
}
|
||||
|
||||
if (inviteId) {
|
||||
await updateInviteAction({ inviteId: inviteId, data: { role } });
|
||||
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
|
||||
@@ -27,15 +27,14 @@ 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,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -43,7 +42,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
return await deleteInvite(parsedInput.inviteId);
|
||||
|
||||
@@ -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 });
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<ProjectConfigNavigation activeId="tags" />
|
||||
<ProjectConfigNavigation activeId="tags" loading />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.workspace.tags.manage_tags")}
|
||||
|
||||
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
|
||||
if (url?.[0]) {
|
||||
if (url?.length) {
|
||||
updateSurvey({ fileUrl: url[0] });
|
||||
} else {
|
||||
updateSurvey({ fileUrl: undefined });
|
||||
}
|
||||
}}
|
||||
fileUrl={localSurvey?.welcomeCard?.fileUrl}
|
||||
|
||||
@@ -56,26 +56,11 @@ export const CustomScriptsInjector = ({
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy inline script content with error handling
|
||||
// Copy inline script content
|
||||
if (script.textContent) {
|
||||
// Wrap inline scripts in try-catch to prevent undefined variable errors
|
||||
const wrappedContent = `
|
||||
(function() {
|
||||
try {
|
||||
${script.textContent}
|
||||
} catch (error) {
|
||||
console.warn("[Formbricks] Custom script error:", error);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
newScript.textContent = wrappedContent;
|
||||
newScript.textContent = script.textContent;
|
||||
}
|
||||
|
||||
// Add error handler for external scripts
|
||||
newScript.onerror = (error) => {
|
||||
console.warn("[Formbricks] Custom script failed to load:", error);
|
||||
};
|
||||
|
||||
document.head.appendChild(newScript);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
|
||||
import { getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { closeSurvey } from "@/lib/survey/widget";
|
||||
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
import { sendUpdatesToBackend } from "@/lib/user/update";
|
||||
import {
|
||||
@@ -316,6 +316,9 @@ export const setup = async (
|
||||
addEventListeners();
|
||||
addCleanupEventListeners();
|
||||
|
||||
// Preload surveys script so it's ready when a survey triggers
|
||||
preloadSurveysScript(configInput.appUrl);
|
||||
|
||||
setIsSetup(true);
|
||||
logger.debug("Set up complete");
|
||||
|
||||
|
||||
@@ -43,17 +43,6 @@ 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>;
|
||||
@@ -260,265 +249,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +106,15 @@ export const renderWidget = async (
|
||||
const overlay = projectOverwrites.overlay ?? project.overlay;
|
||||
const placement = projectOverwrites.placement ?? project.placement;
|
||||
const isBrandingEnabled = project.inAppSurveyBranding;
|
||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
|
||||
let formbricksSurveys: TFormbricksSurveys;
|
||||
try {
|
||||
formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load surveys library: ${String(error)}`);
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
|
||||
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
|
||||
@@ -219,30 +227,87 @@ export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(CONTAINER_ID)?.remove();
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
||||
const config = Config.getInstance();
|
||||
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
|
||||
const SURVEYS_POLL_INTERVAL_MS = 200;
|
||||
|
||||
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
|
||||
|
||||
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
|
||||
|
||||
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
// Apply stored nonce if it was set before surveys package loaded
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
||||
}
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
|
||||
reject(new Error("Formbricks Surveys library did not become available within timeout"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||
}
|
||||
|
||||
if (surveysLoadPromise) {
|
||||
return surveysLoadPromise;
|
||||
}
|
||||
|
||||
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
|
||||
const config = Config.getInstance();
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
waitForSurveysGlobal()
|
||||
.then(resolve)
|
||||
.catch((error: unknown) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
});
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return surveysLoadPromise;
|
||||
};
|
||||
|
||||
let isPreloaded = false;
|
||||
|
||||
export const preloadSurveysScript = (appUrl: string): void => {
|
||||
// Don't preload if already loaded or already preloading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) return;
|
||||
if (isPreloaded) return;
|
||||
|
||||
isPreloaded = true;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "preload";
|
||||
link.as = "script";
|
||||
link.href = `${appUrl}/js/surveys.umd.cjs`;
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
@@ -169,104 +169,4 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user