Compare commits

...

8 Commits

71 changed files with 431 additions and 535 deletions
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading}
/>
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++;
return acc + response.ttc._total;
return acc + responseTtcTotal;
}
return acc;
}, 0);
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++;
}
});
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
return {
meta,
@@ -0,0 +1,33 @@
import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
surveyId: string;
surveyType: string;
environmentId: string;
responseCount: number;
}
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
+9 -20
View File
@@ -1,7 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -9,12 +8,10 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
@@ -27,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -302,25 +300,16 @@ export const POST = async (request: Request) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Sampled PostHog tracking: first response + every 100th
if (POSTHOG_KEY) {
const responseCount = await cache.withCache(
() => getResponseCountBySurveyId(surveyId),
createCacheKey.response.countBySurveyId(surveyId),
60 * 1000
);
const responseCount = await getResponseCountBySurveyId(surveyId);
if (responseCount === 1 || responseCount % 100 === 0) {
capturePostHogEvent(organization.id, "survey_response_received", {
survey_id: surveyId,
survey_type: survey.type,
organization_id: organization.id,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
}
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
environmentId,
responseCount,
});
}
// Send telemetry events
@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
@@ -10,6 +11,8 @@ import {
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
@@ -6,6 +6,8 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -86,6 +88,17 @@ export const GET = withV1ApiWrapper({
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -11,6 +12,8 @@ import {
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import {
TIntegrationSlackConfig,
TIntegrationSlackConfigData,
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
+4
View File
@@ -51,6 +51,8 @@ checksums:
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
@@ -411,6 +413,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -2021,6 +2024,7 @@ checksums:
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
+5 -1
View File
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
return responseValue
.filter((v) => v !== "")
.map(findChoiceByLabel)
.filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung",
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
"unknown_question_type": "Unbekannter Fragetyp",
"use_personal_links": "Nutze persönliche Links",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "This quarter",
"this_year": "This year",
"time_to_complete": "Time to Complete",
"ttc_survey_tooltip": "Average time to complete the survey.",
"ttc_tooltip": "Average time to complete the question.",
"unknown_question_type": "Unknown Question Type",
"use_personal_links": "Use personal links",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este año",
"time_to_complete": "Tiempo para completar",
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
"unknown_question_type": "Tipo de pregunta desconocido",
"use_personal_links": "Usar enlaces personales",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
"time_to_complete": "Temps à compléter",
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
"ttc_tooltip": "Temps moyen pour compléter la question.",
"unknown_question_type": "Type de question inconnu",
"use_personal_links": "Utilisez des liens personnels",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
"time_to_complete": "Tijd om te voltooien",
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
"unknown_question_type": "Onbekend vraagtype",
"use_personal_links": "Gebruik persoonlijke links",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
"ttc_tooltip": "Tempo médio para completar a pergunta.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"use_personal_links": "Use links pessoais",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"use_personal_links": "Utilize links pessoais",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
"time_to_complete": "Timp de finalizare",
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
"unknown_question_type": "Tip de întrebare necunoscut",
"use_personal_links": "Folosește linkuri personale",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
"time_to_complete": "Tid att slutföra",
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
"unknown_question_type": "Okänd frågetyp",
"use_personal_links": "Använd personliga länkar",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
/>
);
} else if (Array.isArray(responseData)) {
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
const itemsArray = responseData
.filter((choice) => choice !== "")
.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
return (
<>
{element.type === TSurveyElementTypeEnum.Ranking ? (
+5
View File
@@ -166,6 +166,11 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
@@ -51,7 +51,7 @@ describe("SSO Providers", () => {
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBe(true);
expect(samlProvider.allowDangerousEmailAccountLinking).toBe(true);
});
});
+2
View File
@@ -26,6 +26,7 @@ export const getSSOProviders = () => [
GoogleProvider({
clientId: GOOGLE_CLIENT_ID || "",
clientSecret: GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
AzureAD({
clientId: AZUREAD_CLIENT_ID || "",
@@ -80,6 +81,7 @@ export const getSSOProviders = () => [
clientId: "dummy",
clientSecret: "dummy",
},
allowDangerousEmailAccountLinking: true,
},
];
+4 -5
View File
@@ -34,8 +34,6 @@ const LINKED_SSO_LOOKUP_SELECT = {
identityProviderAccountId: true,
} as const;
const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked";
const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => {
await upsertAccount(
{
@@ -219,7 +217,7 @@ export const handleSsoCallback = async ({
}
// There is no existing linked account for this identity provider / account id
// check if a user account with this email already exists and fail closed if so
// check if a user account with this email already exists and auto-link it
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
@@ -230,9 +228,10 @@ export const handleSsoCallback = async ({
existingUserId: existingUserWithEmail.id,
existingIdentityProvider: existingUserWithEmail.identityProvider,
},
"SSO callback blocked: existing user found by email without linked provider account"
"SSO callback successful: existing user found by email"
);
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
await syncSsoAccount(existingUserWithEmail.id, account);
return true;
}
contextLogger.debug(
@@ -338,7 +338,7 @@ describe("handleSsoCallback", () => {
);
});
test("should reject verified email users whose SSO provider is not already linked", async () => {
test("should auto-link verified email users whose SSO provider is not already linked", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
id: "existing-user-id",
@@ -349,22 +349,26 @@ describe("handleSsoCallback", () => {
isActive: true,
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("OAuthAccountNotLinked");
expect(upsertAccount).not.toHaveBeenCalled();
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
expect(createBrevoCustomer).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("should reject unverified email users whose SSO provider is not already linked", async () => {
test("should auto-link unverified email users whose SSO provider is not already linked", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
@@ -376,22 +380,26 @@ describe("handleSsoCallback", () => {
isActive: true,
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("OAuthAccountNotLinked");
expect(upsertAccount).not.toHaveBeenCalled();
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
expect(createBrevoCustomer).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("should reject existing users from a different SSO provider when no link exists", async () => {
test("should auto-link existing users from a different SSO provider when no link exists", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
@@ -403,14 +411,53 @@ describe("handleSsoCallback", () => {
isActive: true,
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("OAuthAccountNotLinked");
expect(upsertAccount).not.toHaveBeenCalled();
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
});
test("should auto-link same-email users even when the stored legacy provider account id is stale", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
id: "existing-user-id",
email: mockUser.email,
emailVerified: new Date(),
identityProvider: "google",
identityProviderAccountId: "old-provider-id",
locale: mockUser.locale,
isActive: true,
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
});
@@ -1,401 +0,0 @@
---
title: "Background Job Processing"
description: "How BullMQ works in Formbricks today, including the migrated response pipeline workload."
icon: "code"
---
This page documents the current BullMQ-based background job system in Formbricks and the first real workload that now runs on it: the response pipeline.
## Current State
Formbricks now uses BullMQ as an in-process background job system inside the Next.js web application.
The current implementation includes:
- a shared `@formbricks/jobs` package that owns queue creation, schemas, scheduling, and worker runtime concerns
- a Next.js startup hook that starts one BullMQ worker runtime per Node.js process without blocking app boot
- app-level enqueue helpers for request handlers
- an app-owned BullMQ response pipeline processor that replaces the legacy internal HTTP pipeline route
The first migrated workload is:
- `response-pipeline.process`
This means response-related side effects no longer depend on an internal `fetch()` back into the same app process.
## Why This Exists
The original response pipeline lived behind an internal Next.js route:
```text
apps/web/app/api/(internal)/pipeline
```
That model had a few problems:
- it was tightly coupled to the request lifecycle
- it relied on an internal HTTP hop instead of a typed background-job boundary
- it was harder to observe, retry, and scale safely
BullMQ addresses that by moving post-response work behind a queue while keeping the first version operationally simple for self-hosted users.
## High-Level Architecture
```mermaid
graph TD
A["API route or server code"] --> B["enqueueResponsePipelineEvents()"]
B --> C["getResponseSnapshotForPipeline()"]
B --> D["BackgroundJobProducer.enqueueResponsePipeline()"]
D --> E["BullMQ queue: background-jobs"]
F["instrumentation.ts"] --> G["registerJobsWorker()"]
G --> H["startJobsRuntime()"]
H --> I["BullMQ workers"]
I --> J["response-pipeline.process override"]
J --> K["processResponsePipelineJob()"]
E --> I
E --> L["Redis / Valkey"]
I --> L
```
## Responsibilities By Layer
### App Layer
- `apps/web/app/lib/pipelines.ts`
Owns enqueueing for response pipeline events. It gates queueing, hydrates the response snapshot once, logs failures, and never throws back into request handlers.
- `apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts`
Owns app-specific execution of response-pipeline jobs.
- `apps/web/modules/response-pipeline/lib/handle-integrations.ts`
Owns Slack, Notion, Airtable, and Google Sheets integration fan-out for the pipeline.
- `apps/web/modules/response-pipeline/lib/telemetry.ts`
Owns telemetry dispatch logic used by the response-created path.
- `apps/web/instrumentation-jobs.ts`
Registers the app-owned response-pipeline handler override with the shared BullMQ runtime and schedules retry after transient startup failures.
- `apps/web/lib/jobs/config.ts`
Turns environment configuration into queueing and worker-bootstrap decisions. Queue producers depend on `REDIS_URL`; worker startup additionally depends on `BULLMQ_WORKER_ENABLED`.
### Shared Jobs Layer
- `packages/jobs/src/types.ts`
Defines typed payload schemas such as `TResponsePipelineJobData`.
- `packages/jobs/src/definitions.ts`
Defines stable job names and payload validation.
- `packages/jobs/src/queue.ts`
Owns producer-side enqueueing and scheduling.
- `packages/jobs/src/runtime.ts`
Starts workers, connects Redis, and handles graceful shutdown.
- `packages/jobs/src/processors/registry.ts`
Validates payloads and dispatches named jobs, applying app-provided handler overrides when registered.
## Response Pipeline Flow
The response pipeline now runs fully in the background worker.
### Enqueueing
When a response is created or updated, the request path calls:
```ts
enqueueResponsePipelineEvents({
environmentId,
surveyId,
responseId,
events,
});
```
That helper:
1. deduplicates requested events
2. checks whether BullMQ queueing is enabled
3. uses the just-written response snapshot when the caller already has it
4. otherwise loads the latest response snapshot once via `getResponseSnapshotForPipeline(responseId)` using an uncached read
5. enqueues one BullMQ job per event with the shared snapshot payload
6. waits for the enqueue attempt to complete, then logs enqueue failures without failing the original request
### Execution
At worker startup, `apps/web/instrumentation-jobs.ts` registers an app-owned override for:
- `response-pipeline.process`
That override delegates to `processResponsePipelineJob(...)`, which performs:
- webhook delivery for all pipeline events
- integrations for `responseFinished`
- response-finished notification emails
- follow-up delivery
- survey auto-complete updates and audit logging
- response-created billing metering
- response-created telemetry dispatch
Current retry semantics are intentionally asymmetric:
- webhook delivery failures fail early BullMQ attempts so retries can happen at the job level
- if webhook delivery is still failing on the final BullMQ attempt, the worker logs that retries are exhausted and continues with the remaining event-specific side effects
- integration, email, telemetry, metering, follow-up, and survey auto-complete failures are logged inside the processor and do not fail the whole job
## Acceptance Criteria Review
### Pipeline Execution
Satisfied.
- New response create/update flows enqueue BullMQ jobs instead of calling an internal HTTP route.
- The job payload contains `environmentId`, `surveyId`, `event`, and an authoritative response snapshot.
- The response pipeline executes inside the BullMQ worker runtime.
### Feature Parity
Mostly satisfied for the legacy response pipeline behavior that existed in the old route.
The migrated BullMQ processor preserves:
- webhook delivery
- integrations
- response-finished emails
- follow-up execution
- survey auto-complete and audit logging
- response-created billing metering
- response-created telemetry
One important behavior change still exists today:
- webhook delivery failures delay the remaining side effects until the final BullMQ attempt
That is closer to the legacy route, because the pipeline eventually continues even if webhook delivery never succeeds. It is still not exact feature parity, though, because the legacy route continued immediately while the BullMQ worker waits until retries are exhausted before it degrades webhook failure into a logged condition.
### Architecture
Satisfied.
- Enqueueing lives in the app layer through `apps/web/app/lib/pipelines.ts`.
- Execution lives in the worker path under `apps/web/modules/response-pipeline/lib`.
- `@formbricks/jobs` stays responsible for queue/runtime concerns and typed job contracts.
### Cleanup
Satisfied.
The legacy internal route has been removed:
```text
apps/web/app/api/(internal)/pipeline/route.ts
```
The runtime path no longer depends on the old internal-route folder structure, and the remaining pipeline-only test mock under that deleted folder has been removed as part of the migration cleanup.
### Reliability
Satisfied at the current ticket scope.
BullMQ jobs use shared default retry behavior:
- `attempts: 3`
- exponential backoff starting at `1000ms`
Failures are logged with structured metadata such as:
- `jobId`
- `attempt`
- `jobName`
- `queueName`
- `environmentId`
- `surveyId`
- `responseId`
Request handlers remain non-blocking:
- if Redis is unavailable
- if queueing is disabled
- if snapshot hydration fails
- if enqueueing fails
the request still completes, and the failure is logged.
Worker startup is also non-blocking:
- Next.js boot does not await BullMQ readiness
- startup failures are logged
- the web app schedules a retry instead of requiring an immediate process restart
### Worker Integration
Satisfied.
The response pipeline is processed by the same BullMQ worker runtime started from Next.js instrumentation. No standalone worker service was introduced as part of this migration.
### Developer Experience
Satisfied.
The public app-level API for request handlers is intentionally small:
- `enqueueResponsePipelineEvents(...)`
This keeps queue names, Redis concerns, and BullMQ details out of response routes.
## Comparison With The Legacy Route
### Previous Implementation
The legacy internal route accepted a full response payload directly and then executed the entire pipeline synchronously inside the route handler.
Key characteristics of that model:
- request handlers performed an internal authenticated `fetch()` back into the same app
- the route received the response payload directly instead of hydrating it from a queue-side snapshot
- webhook failures were logged and did not block the rest of the pipeline
- response-finished integrations, emails, follow-ups, and survey auto-complete ran in the same route execution
- response-created metering was fire-and-forget while telemetry was awaited
### Current BullMQ Implementation
The current branch enqueues a typed snapshot-based BullMQ job and executes the pipeline inside the in-process worker registered from Next.js instrumentation.
Key characteristics of the current model:
- request handlers enqueue directly through `enqueueResponsePipelineEvents(...)`
- handlers now pass the just-written `TResponse` snapshot when they already have it
- callers that do not already have a response snapshot use an uncached pipeline-specific lookup
- worker startup is non-blocking and retries after transient failures
- webhook failures fail early attempts so BullMQ can retry them
- on the final attempt, webhook failures are logged and the remaining side effects continue
- response-created metering is awaited before the BullMQ job completes
### Net Result
Compared to the legacy route, the current branch is:
- architecturally stronger
- safer to scale and operate
- easier to observe through structured job logging
- closer to legacy feature parity than the earlier BullMQ iterations on this branch
The main remaining semantic difference is timing:
- the legacy route continued past webhook failures immediately
- the BullMQ worker now continues only after webhook retries are exhausted
That is an intentional trade-off in the current branch, not an accident.
## Current Queue Model
The queue remains intentionally small:
- queue name: `background-jobs`
- prefix: `formbricks:jobs`
- job names:
- `system.test-log`
- `response-pipeline.process`
The response pipeline is the first production workload on this queue.
## Local Development
Local development works end to end as long as Redis is available and the worker is enabled.
Required inputs:
- `REDIS_URL`
- optionally `BULLMQ_WORKER_ENABLED`
- optionally `BULLMQ_WORKER_COUNT`
- optionally `BULLMQ_WORKER_CONCURRENCY`
Behavior:
- if `REDIS_URL` is missing, queueing is skipped
- if `BULLMQ_WORKER_ENABLED=0`, the worker is not started, but request-side enqueueing can still stay enabled in deployments that point at a separate BullMQ worker
- outside tests, the worker is enabled by default
This makes it possible to develop request flows without hard-failing when Redis is absent, while still supporting full local end-to-end verification when Redis is running.
## Operational Notes
### Logging
The current implementation logs:
- worker startup failures
- Redis connection failures
- enqueue failures
- job failures
- webhook delivery failures
- integration failures
- email delivery failures
- follow-up failures
- survey auto-complete update failures
- metering failures
- telemetry failures
### Shutdown
The worker runtime registers `SIGTERM` and `SIGINT` handlers, closes workers and queue handles, and then closes Redis connections. This keeps shutdown behavior predictable inside the web process.
## Current Limitations
The migration satisfies the ticket, but a few larger architectural limits remain by design.
### Dual-Write Boundary
Response writes happen in Postgres and background jobs are enqueued in Redis. Those are separate systems, so this remains a dual-write boundary.
This means Formbricks currently has:
- non-blocking enqueue semantics
- at-least-once background execution
- no transactional guarantee that the product write and Redis enqueue succeed together
That trade-off was accepted for this BullMQ phase.
### In-Process Workers
Workers run inside the Next.js app process.
That keeps self-hosting simple, but it also means:
- job capacity still shares resources with the web process
- heavy background work is still Node.js-local
- scaling job throughput also scales the app runtime
### Webhook-Gated Retries
Webhook delivery still happens before the rest of the `responseFinished` side effects.
That gives Formbricks job-level retries for webhook delivery, but it also means:
- `responseFinished` side effects do not run on the early retry attempts
- the remaining side effects only continue after webhook retries are exhausted
- this is closer to legacy behavior than failing forever, but it is still not immediate parity
This is the current behavior of the branch and should be evaluated explicitly if we want stricter feature parity with the legacy route.
### Logs-First Observability
The current system has strong structured logging, but it does not yet provide:
- queue dashboards
- retry tooling
- latency metrics
- product-native workflow inspection
Those are future improvements, not blockers for the current migration.
## Recommended Next Steps
Now that the response pipeline is on BullMQ, the most useful next steps are:
1. migrate additional low-risk async workloads behind the same producer/runtime boundary
2. add queue metrics and worker health visibility beyond logs
3. define explicit idempotency rules for side-effect-heavy jobs
4. decide which future workloads should remain Node-local and which should eventually move to a different runtime
## Practical Conclusion
Formbricks now has:
- a production-capable BullMQ foundation
- a real migrated workload
- a clean separation between request-time enqueueing and background execution
The response pipeline migration should be considered complete for the current ticket scope.
@@ -280,6 +280,7 @@ function DropdownVariant({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -401,6 +402,7 @@ function ListVariant({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
ref={otherInputRef}
@@ -272,6 +272,7 @@ function SingleSelect({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -334,6 +335,7 @@ function SingleSelect({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -4,17 +4,16 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
custom: "button-custom",
},
@@ -225,7 +225,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
@@ -11,7 +11,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input-border dark:bg-input/30 data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground dark:data-[state=checked]:bg-brand data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input-border data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
@@ -58,7 +58,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -41,7 +41,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
// Focus ring
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
// Error state ring
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
// Disabled state
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
@@ -31,7 +31,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
className={cn(
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
+1
View File
@@ -12,6 +12,7 @@
"da",
"de",
"es",
"et",
"fr",
"hi",
"hu",
+1
View File
@@ -34,6 +34,7 @@ checksums:
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
common/welcome_video: 1f87e84c0a563c2522eef5cb71a1f95c
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
errors/all_rows_must_be_answered: 295f41a0ef04cbb3491c798053c61abd
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "شروط الخدمة",
"the_servers_cannot_be_reached_at_the_moment": "لا يمكن الوصول إلى الخوادم في الوقت الحالي.",
"they_will_be_redirected_immediately": "سيتم إعادة توجيههم فورًا",
"welcome_video": "فيديو بطاقة الترحيب",
"your_feedback_is_stuck": "تعليقك عالق :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Vilkår for brug",
"the_servers_cannot_be_reached_at_the_moment": "Serverne kan ikke kontaktes lige nu.",
"they_will_be_redirected_immediately": "De bliver straks omdirigeret",
"welcome_video": "Velkomstkortvideo",
"your_feedback_is_stuck": "Din feedback sidder fast :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Nutzungsbedingungen",
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
"welcome_video": "Willkommenskarten-Video",
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately",
"welcome_video": "Welcome Card video",
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Términos de servicio",
"the_servers_cannot_be_reached_at_the_moment": "Los servidores no pueden ser alcanzados en este momento.",
"they_will_be_redirected_immediately": "Serán redirigidos inmediatamente",
"welcome_video": "Vídeo de la tarjeta de bienvenida",
"your_feedback_is_stuck": "Tu feedback está atascado :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"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",
"welcome_video": "Tervituskaardi video",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Conditions d'utilisation",
"the_servers_cannot_be_reached_at_the_moment": "Les serveurs ne sont pas accessibles pour le moment.",
"they_will_be_redirected_immediately": "Ils seront redirigés immédiatement",
"welcome_video": "Vidéo de la carte de bienvenue",
"your_feedback_is_stuck": "Votre feedback est bloqué :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "सेवा की शर्तें",
"the_servers_cannot_be_reached_at_the_moment": "इस समय सर्वर तक पहुंचा नहीं जा सकता है।",
"they_will_be_redirected_immediately": "उन्हें तुरंत रीडायरेक्ट किया जाएगा",
"welcome_video": "स्वागत कार्ड वीडियो",
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Használati feltételek",
"the_servers_cannot_be_reached_at_the_moment": "Jelenleg nem lehet elérni a kiszolgálókat.",
"they_will_be_redirected_immediately": "Azonnal át lesznek irányítva",
"welcome_video": "Üdvözlő kártya videó",
"your_feedback_is_stuck": "A visszajelzése elakadt :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termini di servizio",
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
"welcome_video": "Video della scheda di benvenuto",
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "利用規約",
"the_servers_cannot_be_reached_at_the_moment": "現在サーバーに接続できません。",
"they_will_be_redirected_immediately": "すぐにリダイレクトされます",
"welcome_video": "ウェルカムカード動画",
"your_feedback_is_stuck": "フィードバックが送信できません :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Servicevoorwaarden",
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
"welcome_video": "Welkomstkaart video",
"your_feedback_is_stuck": "Je feedback blijft hangen :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termos de serviço",
"the_servers_cannot_be_reached_at_the_moment": "Os servidores não podem ser alcançados no momento.",
"they_will_be_redirected_immediately": "Eles serão redirecionados imediatamente",
"welcome_video": "Vídeo do Cartão de Boas-vindas",
"your_feedback_is_stuck": "Seu feedback está preso :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termeni și condiții",
"the_servers_cannot_be_reached_at_the_moment": "Serverele nu pot fi accesate momentan.",
"they_will_be_redirected_immediately": "Vor fi redirecționați imediat",
"welcome_video": "Videoclip Card de bun venit",
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Условия использования",
"the_servers_cannot_be_reached_at_the_moment": "Сервера в данный момент недоступны.",
"they_will_be_redirected_immediately": "Они будут немедленно перенаправлены",
"welcome_video": "Видео приветственной карточки",
"your_feedback_is_stuck": "Ваш отзыв застрял :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Användarvillkor",
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
"welcome_video": "Välkomstkortvideo",
"your_feedback_is_stuck": "Din feedback fastnade :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Xizmat ko'rsatish shartlari",
"the_servers_cannot_be_reached_at_the_moment": "Hozirda serverlarga ulanish imkoni yo'q.",
"they_will_be_redirected_immediately": "Ular darhol yo'naltiriladi",
"welcome_video": "Xush kelibsiz kartasi videosi",
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "服务条款",
"the_servers_cannot_be_reached_at_the_moment": "目前无法连接到服务器。",
"they_will_be_redirected_immediately": "他们将立即被重定向",
"welcome_video": "欢迎卡片视频",
"your_feedback_is_stuck": "您的反馈卡住了 :("
},
"errors": {
+2 -1
View File
@@ -49,7 +49,8 @@
"i18next-icu": "2.4.3",
"isomorphic-dompurify": "3.1.0",
"preact": "10.29.0",
"react-i18next": "16.5.8"
"react-i18next": "16.5.8",
"tailwind-merge": "3.5.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -169,8 +169,7 @@ export function MultipleChoiceMultiElement({
setOtherValue(newOtherValue);
const baseLabels = getNormalizedSelectedLabels();
const nextValue = [...baseLabels, ""];
if (newOtherValue.trim()) nextValue.push(newOtherValue);
const nextValue = [...baseLabels, newOtherValue];
onChange({ [element.id]: nextValue });
};
@@ -227,8 +226,7 @@ export function MultipleChoiceMultiElement({
});
if (isOtherNowSelected) {
nextLabels.push("");
if (otherValue.trim()) nextLabels.push(otherValue);
nextLabels.push(otherValue);
} else if (otherValue) {
// If other was deselected, clear any stale other value
setOtherValue("");
@@ -23,15 +23,16 @@ interface ElementMediaProps {
imgUrl?: string;
videoUrl?: string;
altText?: string;
className?: string;
}
export function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: ElementMediaProps) {
export function ElementMedia({ imgUrl, videoUrl, altText = "Image", className }: ElementMediaProps) {
const { t } = useTranslation();
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = useState(true);
return (
<div className="group/image relative mb-6 block min-h-40 rounded-md">
<div className={cn("group/image relative mb-6 block min-h-40 rounded-md", className)}>
{isLoading ? (
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
) : null}
@@ -539,8 +539,8 @@ export function Survey({
// --- Warn before leaving mid-survey or with unsent offline responses ---
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// Warn if user has started answering but hasn't finished the survey
if (history.length > 0 && !isSurveyFinished) {
// Warn if user has started answering but hasn't finished the survey (only when offline support is active)
if (offlinePersistEnabled && history.length > 0 && !isSurveyFinished) {
e.preventDefault();
return;
}
@@ -147,8 +147,10 @@ export function WelcomeCard({
return (
<ScrollableContainer fullSizeCards={fullSizeCards}>
<div>
{fileUrl || videoUrl ? (
<ElementMedia imgUrl={fileUrl} videoUrl={videoUrl} altText={t("common.company_logo")} />
{fileUrl ? (
<ElementMedia imgUrl={fileUrl} altText={t("common.company_logo")} className="mb-8 min-h-0 w-1/4" />
) : videoUrl ? (
<ElementMedia videoUrl={videoUrl} altText={t("common.welcome_video")} />
) : null}
<Headline
+62 -3
View File
@@ -32,16 +32,32 @@ export const delay = (ms: number): Promise<void> => {
});
};
// Module-level locks keyed by surveyId.
// Survive ResponseQueue instance recreation (e.g. React useMemo recomputation)
// so that only one sync/send runs at a time per survey, even across instances.
const syncingBySurvey = new Map<string, boolean>();
const requestInProgressBySurvey = new Map<string, boolean>();
/** @internal Exposed for tests only. */
export const _syncLocks = {
clear: () => {
syncingBySurvey.clear();
requestInProgressBySurvey.clear();
},
set: (surveyId: string, value: boolean) => syncingBySurvey.set(surveyId, value),
get: (surveyId: string) => syncingBySurvey.get(surveyId) ?? false,
setRequestInProgress: (surveyId: string, value: boolean) => requestInProgressBySurvey.set(surveyId, value),
getRequestInProgress: (surveyId: string) => requestInProgressBySurvey.get(surveyId) ?? false,
};
export class ResponseQueue {
readonly queue: TResponseUpdate[] = [];
readonly config: QueueConfig;
private surveyState: SurveyState;
private isRequestInProgress = false;
readonly api: ApiClient;
private responseRecaptchaToken?: string;
// Maps in-memory queue index → IndexedDB id for cleanup after successful send
private readonly pendingDbIds: Map<TResponseUpdate, number> = new Map();
private isSyncing = false;
constructor(config: QueueConfig, surveyState: SurveyState) {
this.config = config;
@@ -52,6 +68,26 @@ export class ResponseQueue {
});
}
private get isSyncing(): boolean {
return this.config.surveyId ? (syncingBySurvey.get(this.config.surveyId) ?? false) : false;
}
private set isSyncing(value: boolean) {
if (this.config.surveyId) {
syncingBySurvey.set(this.config.surveyId, value);
}
}
private get isRequestInProgress(): boolean {
return this.config.surveyId ? (requestInProgressBySurvey.get(this.config.surveyId) ?? false) : false;
}
private set isRequestInProgress(value: boolean) {
if (this.config.surveyId) {
requestInProgressBySurvey.set(this.config.surveyId, value);
}
}
setResponseRecaptchaToken(token?: string) {
this.responseRecaptchaToken = token;
}
@@ -111,8 +147,26 @@ export class ResponseQueue {
return { success: false };
}
this.isRequestInProgress = true;
// When offline support is active and there are multiple pending entries in
// IndexedDB, defer to syncPersistedResponses which drains them in order.
// This prevents processQueue and syncPersistedResponses from racing to
// create the same response concurrently (duplicate POSTs).
if (this.config.persistOffline && this.config.surveyId) {
const pendingCount = await countPendingResponses(this.config.surveyId);
// Re-check after await — another processQueue/sync may have started during the yield
if (this.isSyncing || this.isRequestInProgress || this.queue.length === 0) {
return { success: false };
}
if (pendingCount > 1) {
void this.syncPersistedResponses();
return { success: false };
}
}
const responseUpdate = this.queue[0];
this.isRequestInProgress = true;
const result = await this.sendResponseWithRetry(responseUpdate);
@@ -169,6 +223,11 @@ export class ResponseQueue {
// Concurrency guard: prevent duplicate syncs from online/offline flicker
if (this.isSyncing) return { success: false, syncedCount: 0 };
// If processQueue already has a request in flight, don't start syncing —
// let it finish first to avoid both paths creating the same response.
if (this.isRequestInProgress) return { success: false, syncedCount: 0 };
this.isSyncing = true;
try {
+41 -29
View File
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import { err, ok } from "@formbricks/types/error-handlers";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
import { ResponseQueue, delay } from "./response-queue";
import { ResponseQueue, _syncLocks, delay } from "./response-queue";
import { SurveyState } from "./survey-state";
// Suppress noisy console output from retry logic during tests
@@ -86,6 +86,7 @@ describe("ResponseQueue", () => {
queue = new ResponseQueue(config, surveyState);
apiMock = queue.api;
vi.clearAllMocks();
_syncLocks.clear();
});
test("constructor initializes properties", () => {
@@ -110,26 +111,30 @@ describe("ResponseQueue", () => {
});
test("processQueue does nothing if request in progress or queue empty", async () => {
queue["isRequestInProgress"] = true;
await queue.processQueue();
queue["isRequestInProgress"] = false;
queue.queue.length = 0;
await queue.processQueue();
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
_syncLocks.setRequestInProgress("s1", true);
await reqQueue.processQueue();
_syncLocks.setRequestInProgress("s1", false);
reqQueue.queue.length = 0;
await reqQueue.processQueue();
expect(true).toBe(true); // just to ensure no errors
});
test("processQueue sends response and removes from queue on success", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(ok(true));
await queue.processQueue();
expect(queue.queue.length).toBe(0);
expect(queue["isRequestInProgress"]).toBe(false);
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
reqQueue.queue.push(responseUpdate);
vi.spyOn(reqQueue, "sendResponse").mockResolvedValue(ok(true));
await reqQueue.processQueue();
expect(reqQueue.queue.length).toBe(0);
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue retries and calls onResponseSendingFailed on recaptcha error", async () => {
queue.queue.push(responseUpdate);
const recaptchaConfig = getConfig({ surveyId: "s1" });
const recaptchaQueue = new ResponseQueue(recaptchaConfig, getSurveyState());
recaptchaQueue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(
vi.spyOn(recaptchaQueue, "sendResponse").mockResolvedValue(
err({
code: "internal_server_error",
message: "An error occurred while sending the response.",
@@ -139,29 +144,31 @@ describe("ResponseQueue", () => {
},
})
);
await queue.processQueue();
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
await recaptchaQueue.processQueue();
expect(recaptchaConfig.onResponseSendingFailed).toHaveBeenCalledWith(
responseUpdate,
TResponseErrorCodesEnum.RecaptchaError
);
expect(queue["isRequestInProgress"]).toBe(false);
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue retries and calls onResponseSendingFailed after max attempts", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(
const reqConfig = getConfig({ surveyId: "s1" });
const reqQueue = new ResponseQueue(reqConfig, getSurveyState());
reqQueue.queue.push(responseUpdate);
vi.spyOn(reqQueue, "sendResponse").mockResolvedValue(
err({
code: "internal_server_error",
message: "An error occurred while sending the response.",
status: 500,
})
);
await queue.processQueue();
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
await reqQueue.processQueue();
expect(reqConfig.onResponseSendingFailed).toHaveBeenCalledWith(
responseUpdate,
TResponseErrorCodesEnum.ResponseSendingError
);
expect(queue["isRequestInProgress"]).toBe(false);
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue calls onResponseSendingFinished if finished", async () => {
@@ -218,8 +225,9 @@ describe("ResponseQueue", () => {
});
test("processQueueAsync returns success false if request in progress", async () => {
queue["isRequestInProgress"] = true;
const result = await queue.processQueue();
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
_syncLocks.setRequestInProgress("s1", true);
const result = await reqQueue.processQueue();
expect(result.success).toBe(false);
});
@@ -309,9 +317,13 @@ describe("ResponseQueue", () => {
});
test("processQueue returns false when isSyncing is true", async () => {
queue.queue.push(responseUpdate);
queue["isSyncing"] = true;
const result = await queue.processQueue();
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push(responseUpdate);
_syncLocks.set("s1", true);
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
});
@@ -347,7 +359,7 @@ describe("ResponseQueue", () => {
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue["isSyncing"] = true;
_syncLocks.set("s1", true);
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
@@ -382,7 +394,7 @@ describe("ResponseQueue", () => {
expect(result).toEqual({ success: true, syncedCount: 1 });
expect(removePendingResponse).toHaveBeenCalledWith(10);
expect(offlineQueue.queue.length).toBe(0);
expect(offlineQueue["isSyncing"]).toBe(false);
expect(_syncLocks.get("s1")).toBe(false);
});
test("syncPersistedResponses stops on server error", async () => {
@@ -415,7 +427,7 @@ describe("ResponseQueue", () => {
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
expect(offlineQueue["isSyncing"]).toBe(false);
expect(_syncLocks.get("s1")).toBe(false);
});
test("syncPersistedResponses retries 404 as createResponse by resetting responseId", async () => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -11,8 +12,8 @@ import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/type
import { type TShuffleOption } from "@formbricks/types/surveys/types";
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
export const cn = (...classes: (string | undefined)[]) => {
return twMerge(classes.filter(Boolean).join(" "));
};
export const getSecureRandom = (): number => {
@@ -154,6 +154,17 @@ const checkRequiredField = (
return createRequiredError(t);
}
// For multi-select: if "other" is selected (sentinel ""), require the other text to be non-empty
if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) {
const sentinelIndex = value.indexOf("");
if (sentinelIndex !== -1) {
const otherText = value[sentinelIndex + 1];
if (!otherText || (typeof otherText === "string" && otherText.trim() === "")) {
return createRequiredError(t);
}
}
}
return null;
};
+3
View File
@@ -990,6 +990,9 @@ importers:
react-i18next:
specifier: 16.5.8
version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
tailwind-merge:
specifier: 3.5.0
version: 3.5.0
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*