Compare commits

...

3 Commits

Author SHA1 Message Date
Johannes
5b50605058 fix: await user sync completion in setUserId and setAttributes
Previously, setUserId() and setAttributes() returned immediately after
queuing the update, but the API sync (which sets contactId) happened
asynchronously with a 500ms debounce. This caused a race condition where
surveys could be displayed as anonymous if shown before the sync completed.

Changes:
- Make setUserId() and setAttributes() await processUpdates() completion
- Add queue.wait() to public API functions (setUserId, setAttribute,
  setAttributes, setEmail, setLanguage) to ensure commands execute
- Add error handling for network failures during sync
- Update tests to reflect new async behavior

This ensures that when customers await these functions, the contactId
is properly set before the promise resolves, preventing anonymous
survey responses.
2025-12-18 19:07:59 +01:00
Anshuman Pandey
a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes
7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
20 changed files with 138 additions and 40 deletions

View File

@@ -1910,9 +1910,9 @@ checksums:
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044

View File

@@ -2048,7 +2048,7 @@
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> weltweit basiert.",
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
"paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
"welcome_to_formbricks": "Willkommen bei Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Get started",
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"welcome_to_formbricks": "Welcome to Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Comenzar",
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Commencer",
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> au monde.",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "始める",
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
"paragraph_3": "私たちは最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
"welcome_to_formbricks": "Formbricksへようこそ"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Ga aan de slag",
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
"paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
"welcome_to_formbricks": "Welkom bij Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
"paragraph_1": "Formbricks este o suită de management al experienței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "Kom igång",
"made_with_love_in_kiel": "Gjort med 🤍 i Tyskland",
"paragraph_1": "Formbricks är en Experience Management Suite byggd av den <b>snabbast växande öppenkällkods enkätplattformen</b> i världen.",
"paragraph_1": "Formbricks är en Experience Management Suite byggd den <b>snabbast växande open source-enkätplattformen</b> i världen.",
"paragraph_2": "Kör riktade enkäter på webbplatser, i appar eller var som helst online. Samla värdefulla insikter för att <b>skapa oemotståndliga upplevelser</b> för kunder, användare och anställda.",
"paragraph_3": "Vi är engagerade i högsta grad av dataintegritet. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"paragraph_3": "Vi är engagerade i högsta möjliga datasekretess. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"welcome_to_formbricks": "Välkommen till Formbricks!"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "开始使用",
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
"paragraph_1": "Formbricks 是一体验管理套件, 基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_1": "Formbricks 是一体验管理套件,基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
"welcome_to_formbricks": "欢迎来到 Formbricks !"
},
"invite": {

View File

@@ -2046,9 +2046,9 @@
"intro": {
"get_started": "開始使用",
"made_with_love_in_kiel": "用 🤍 在德國製造",
"paragraph_1": "Formbricks 是一套體驗管理套件,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_1": "Formbricks 是一套體驗管理工具,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
"paragraph_3": "我們致力於最高程度的資料隱私。自託管<b>完全掌控您的資料</b>。",
"paragraph_3": "我們致力於最高等級的資料隱私。自託管,讓您<b>完全掌控您的資料</b>。",
"welcome_to_formbricks": "歡迎使用 Formbricks"
},
"invite": {

View File

@@ -22,6 +22,7 @@ interface EditWelcomeCardProps {
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const EditWelcomeCard = ({
@@ -34,6 +35,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: EditWelcomeCardProps) => {
const { t } = useTranslation();
@@ -65,7 +67,7 @@ export const EditWelcomeCard = ({
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />
@@ -135,6 +137,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
<div className="mt-3">
@@ -150,6 +153,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
@@ -170,6 +174,7 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>

View File

@@ -808,6 +808,7 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
)}

View File

@@ -42,22 +42,27 @@ const setup = async (setupConfig: TConfigInput): Promise<void> => {
const setUserId = async (userId: string): Promise<void> => {
await queue.add(User.setUserId, CommandType.UserAction, true, userId);
await queue.wait();
};
const setEmail = async (email: string): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
await queue.wait();
};
const setAttribute = async (key: string, value: string): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { [key]: value });
await queue.wait();
};
const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
await queue.wait();
};
const setLanguage = async (language: string): Promise<void> => {
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
await queue.wait();
};
const logout = async (): Promise<void> => {

View File

@@ -1,12 +1,25 @@
import { Logger } from "@/lib/common/logger";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";
import { type NetworkError, type Result, err, okVoid } from "@/types/error";
export const setAttributes = async (
attributes: Record<string, string>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
const logger = Logger.getInstance();
const updateQueue = UpdateQueue.getInstance();
updateQueue.updateAttributes(attributes);
void updateQueue.processUpdates();
return okVoid();
try {
await updateQueue.processUpdates();
return okVoid();
} catch (error) {
logger.error(
`Failed to process attribute updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
return err({
code: "network_error",
message: "Failed to sync attributes",
responseMessage: error instanceof Error ? error.message : "Unknown error",
status: 500,
});
}
};

View File

@@ -17,6 +17,16 @@ vi.mock("@/lib/user/update-queue", () => ({
},
}));
// Mock the Logger
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
error: vi.fn(),
debug: vi.fn(),
})),
},
}));
describe("User Attributes", () => {
const mockUpdateQueue = {
updateAttributes: vi.fn(),
@@ -32,6 +42,8 @@ describe("User Attributes", () => {
describe("setAttributes", () => {
test("successfully updates attributes and triggers processing", async () => {
mockUpdateQueue.processUpdates.mockResolvedValue(undefined);
const result = await setAttributes(mockAttributes);
// Verify UpdateQueue methods were called correctly
@@ -43,6 +55,8 @@ describe("User Attributes", () => {
});
test("processes multiple attribute updates", async () => {
mockUpdateQueue.processUpdates.mockResolvedValue(undefined);
const firstAttributes = { name: mockAttributes.name };
const secondAttributes = { email: mockAttributes.email };
@@ -55,22 +69,35 @@ describe("User Attributes", () => {
expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2);
});
test("processes updates asynchronously", async () => {
test("waits for processUpdates to complete", async () => {
const attributes = { name: mockAttributes.name };
let processUpdatesResolved = false;
// Mock processUpdates to be async
// Mock processUpdates to be async and set a flag when resolved
mockUpdateQueue.processUpdates.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 100);
setTimeout(() => {
processUpdatesResolved = true;
resolve(undefined);
}, 100);
})
);
const result = await setAttributes(attributes);
const resultPromise = setAttributes(attributes);
expect(result.ok).toBe(true);
// Verify processUpdates was called
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
// The function returns before processUpdates completes due to void operator
// Verify the function hasn't resolved yet
expect(processUpdatesResolved).toBe(false);
// Wait for setAttributes to complete
const result = await resultPromise;
// Verify it completed after processUpdates
expect(processUpdatesResolved).toBe(true);
expect(result.ok).toBe(true);
});
});
});

View File

@@ -101,7 +101,7 @@ describe("user.ts", () => {
const mockUpdateQueue = {
updateUserId: vi.fn(),
processUpdates: vi.fn(),
processUpdates: vi.fn().mockResolvedValue(undefined),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
@@ -113,6 +113,42 @@ describe("user.ts", () => {
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
});
test("returns error if processUpdates fails", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: {
data: {
userId: null,
},
},
}),
};
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
const mockUpdateQueue = {
updateUserId: vi.fn(),
processUpdates: vi.fn().mockRejectedValue(new Error("Network error")),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
const result = await setUserId(mockUserId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.status).toBe(500);
}
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalled();
});
});
describe("logout", () => {

View File

@@ -4,7 +4,6 @@ import { tearDown } from "@/lib/common/setup";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type ApiErrorResponse, type Result, err, okVoid } from "@/types/error";
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
const appConfig = Config.getInstance();
const logger = Logger.getInstance();
@@ -27,8 +26,20 @@ export const setUserId = async (userId: string): Promise<Result<void, ApiErrorRe
}
updateQueue.updateUserId(userId);
void updateQueue.processUpdates();
return okVoid();
try {
await updateQueue.processUpdates();
return okVoid();
} catch (error) {
logger.error(
`Failed to process userId update: ${error instanceof Error ? error.message : "Unknown error"}`
);
return err({
code: "network_error",
message: "Failed to sync userId",
responseMessage: error instanceof Error ? error.message : "Unknown error",
status: 500,
});
}
};
export const logout = (): Result<void> => {