Compare commits

..

1 Commits

Author SHA1 Message Date
Balázs Úr
89bedc589a chore: change LINGO_API_KEY environment variable name 2026-03-18 13:57:12 +01:00
27 changed files with 154 additions and 470 deletions

View File

@@ -2,16 +2,21 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -92,3 +97,68 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -15,6 +14,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => {
const $chatwoot = getChatwoot();
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (getChatwoot()) {
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = getChatwoot();
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};

View File

@@ -1,18 +0,0 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});

View File

@@ -808,7 +808,6 @@ checksums:
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -1633,13 +1632,13 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d

View File

@@ -1,19 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
export const onRequestError: Instrumentation.onRequestError = (...args) => {
const [error] = args;
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
// These are handled gracefully in the UI and don't need server-side Sentry reporting
if (error instanceof Error && isExpectedError(error)) {
return;
}
Sentry.captureRequestError(...args);
};
export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach",
"six_points": "6 Punkte",
"smiley": "Smiley",
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
"spam_protection_threshold_heading": "Antwortschwelle",
"shrink_preview": "Vorschau verkleinern",
"star": "Stern",
"starts_with": "Fängt an mit",
"state": "Bundesland",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Smiley",
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
"spam_protection_threshold_heading": "Response threshold",
"shrink_preview": "Shrink Preview",
"star": "Star",
"starts_with": "Starts with",
"state": "State",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple",
"six_points": "6 puntos",
"smiley": "Emoticono",
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
"spam_protection_threshold_heading": "Umbral de respuesta",
"shrink_preview": "Contraer vista previa",
"star": "Estrella",
"starts_with": "Comienza con",
"state": "Estado",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"no_triggers": "Aucun déclencheur",
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"shrink_preview": "Réduire l'aperçu",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Sourire",
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
"spam_protection_threshold_heading": "Seuil de réponse",
"shrink_preview": "Réduire l'aperçu",
"star": "Étoile",
"starts_with": "Commence par",
"state": "État",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
"shrink_preview": "Előnézet összecsukása",
"simple": "Egyszerű",
"six_points": "6 pont",
"smiley": "Hangulatjel",
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
"spam_protection_threshold_heading": "Válasz küszöbszintje",
"shrink_preview": "Előnézet összecsukása",
"star": "Csillag",
"starts_with": "Ezzel kezdődik",
"state": "Állapot",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "成功Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
"no_triggers": "トリガーなし",
"please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください",
"response_created": "回答作成",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
"simple": "シンプル",
"six_points": "6点",
"smiley": "スマイリー",
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
"spam_protection_threshold_heading": "回答のしきい値",
"shrink_preview": "プレビューを縮小",
"star": "星",
"starts_with": "で始まる",
"state": "都道府県",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
"endpoint_pinged_error": "Kan de webhook niet pingen!",
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
"no_triggers": "Geen triggers",
"please_check_console": "Controleer de console voor meer details",
"please_enter_a_url": "Voer een URL in",
"response_created": "Reactie gemaakt",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Toon onderzoek maximaal",
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
"shrink_preview": "Voorbeeld invouwen",
"simple": "Eenvoudig",
"six_points": "6 punten",
"smiley": "Smiley",
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
"spam_protection_threshold_heading": "Reactiedrempel",
"shrink_preview": "Voorbeeld invouwen",
"star": "Ster",
"starts_with": "Begint met",
"state": "Staat",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
"endpoint_pinged_error": "Não consegui pingar o webhook!",
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
"no_triggers": "Nenhum Gatilho",
"please_check_console": "Por favor, verifica o console para mais detalhes",
"please_enter_a_url": "Por favor, insira uma URL",
"response_created": "Resposta Criada",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Recolher prévia",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
"no_triggers": "Sem Acionadores",
"please_check_console": "Por favor, verifique a consola para mais detalhes",
"please_enter_a_url": "Por favor, insira um URL",
"response_created": "Resposta Criada",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Reduzir pré-visualização",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
"learn_to_verify": "Află cum să verifici semnăturile webhook",
"no_triggers": "Fără declanșatori",
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
"please_enter_a_url": "Vă rugăm să introduceți un URL",
"response_created": "Răspuns creat",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Afișează sondajul de maxim",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
"simple": "Simplu",
"six_points": "6 puncte",
"smiley": "Smiley",
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
"spam_protection_threshold_heading": "Pragul răspunsurilor",
"shrink_preview": "Restrânge previzualizarea",
"star": "Stea",
"starts_with": "Începe cu",
"state": "Stare",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
"no_triggers": "Нет триггеров",
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
"please_enter_a_url": "Пожалуйста, введите URL",
"response_created": "Ответ создан",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Показать опрос максимум",
"show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
"simple": "Простой",
"six_points": "6 баллов",
"smiley": "Смайлик",
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
"spam_protection_threshold_heading": "Порог ответа",
"shrink_preview": "Свернуть предпросмотр",
"star": "Звезда",
"starts_with": "Начинается с",
"state": "Состояние",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
"endpoint_pinged_error": "Kunde inte nå webhooken!",
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
"no_triggers": "Inga utlösare",
"please_check_console": "Vänligen kontrollera konsolen för mer information",
"please_enter_a_url": "Vänligen ange en URL",
"response_created": "Svar skapat",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "Visa enkät maximalt",
"show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
"simple": "Enkel",
"six_points": "6 poäng",
"smiley": "Smiley",
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
"spam_protection_threshold_heading": "Svarströskel",
"shrink_preview": "Minimera förhandsgranskning",
"star": "Stjärna",
"starts_with": "Börjar med",
"state": "Delstat",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
"endpoint_pinged_error": "无法 ping 该 webhook",
"learn_to_verify": "了解如何验证 webhook 签名",
"no_triggers": "无触发器",
"please_check_console": "请查看控制台以获取更多详情",
"please_enter_a_url": "请输入一个 URL",
"response_created": "创建 响应",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户",
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
"shrink_preview": "收起预览",
"simple": "简单",
"six_points": "6 分",
"smiley": "笑脸",
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
"spam_protection_threshold_heading": "响应 阈值",
"shrink_preview": "收起预览",
"star": "星",
"starts_with": "以...开始",
"state": "状态",

View File

@@ -855,7 +855,6 @@
"endpoint_pinged": "耶!我們能夠 ping Webhook",
"endpoint_pinged_error": "無法 ping Webhook",
"learn_to_verify": "了解如何驗證 webhook 簽章",
"no_triggers": "無觸發條件",
"please_check_console": "請檢查主控台以取得更多詳細資料",
"please_enter_a_url": "請輸入網址",
"response_created": "已建立回應",
@@ -1706,13 +1705,13 @@
"show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
"shrink_preview": "收合預覽",
"simple": "簡單",
"six_points": "6 分",
"smiley": "表情符號",
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
"spam_protection_threshold_heading": "回應閾值",
"shrink_preview": "收合預覽",
"star": "星形",
"starts_with": "開頭為",
"state": "州/省",

View File

@@ -9,33 +9,21 @@ import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
let surveyNames: string[];
if (webhook.surveyIds.length === 0) {
surveyNames = allSurveys.map((survey) => survey.name);
const allSurveyNames = allSurveys.map((survey) => survey.name);
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
} else {
surveyNames = webhook.surveyIds
.map((surveyId) => {
const survey = allSurveys.find((s) => s.id === surveyId);
return survey ? survey.name : "";
})
.filter(Boolean);
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
const survey = allSurveys.find((survey) => survey.id === surveyId);
return survey ? survey.name : "";
});
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
}
if (surveyNames.length === 0) {
return <p className="text-slate-400">-</p>;
}
return (
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
{surveyNames.join(", ")}
</p>
);
};
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
if (webhook.triggers.length === 0) {
return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
return <p className="text-slate-400">No Triggers</p>;
} else {
let cleanedTriggers = webhook.triggers.map((trigger) => {
if (trigger === "responseCreated") {

View File

@@ -11,7 +11,7 @@ export const TagsLoading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation activeId="tags" loading />
<ProjectConfigNavigation activeId="tags" />
</PageHeader>
<SettingsCard
title={t("environments.workspace.tags.manage_tags")}

View File

@@ -26,27 +26,22 @@ import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getProject } from "./lib/project";
/**
* Checks if survey follow-ups can be added for the given organization.
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
* Only throws when new follow-ups are being added.
* Checks if survey follow-ups are enabled for the given organization.
*
* @param { string } organizationId The ID of the organization to check.
* @returns { Promise<void> } A promise that resolves if the permission is granted.
* @throws { ResourceNotFoundError } If the organization is not found.
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (
organizationId: string,
newFollowUpIds: string[],
oldFollowUpIds: Set<string>
): Promise<void> => {
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (isSurveyFollowUpsEnabled) return;
for (const id of newFollowUpIds) {
if (!oldFollowUpIds.has(id)) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
@@ -76,19 +71,14 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
await checkSpamProtectionPermission(organizationId);
}
if (survey.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = survey.id;
const oldObject = await getSurvey(survey.id);
if (survey.followUps.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
survey.followUps.map((f) => f.id),
oldFollowUpIds
);
}
await checkExternalUrlsPermission(organizationId, survey, oldObject);
// Use the draft version that skips validation
@@ -126,19 +116,14 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
await checkSpamProtectionPermission(organizationId);
}
if (parsedInput.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.id;
const oldObject = await getSurvey(parsedInput.id);
if (parsedInput.followUps?.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
parsedInput.followUps.map((f) => f.id),
oldFollowUpIds
);
}
// Check external URLs permission (with grandfathering)
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
const result = await updateSurvey(parsedInput);

View File

@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
import { closeSurvey } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -316,9 +316,6 @@ export const setup = async (
addEventListeners();
addCleanupEventListeners();
// Preload surveys script so it's ready when a survey triggers
preloadSurveysScript(configInput.appUrl);
setIsSetup(true);
logger.debug("Set up complete");

View File

@@ -70,12 +70,6 @@ vi.mock("@/lib/survey/no-code-action", () => ({
checkPageUrl: vi.fn(),
}));
// 9) Mock survey widget
vi.mock("@/lib/survey/widget", () => ({
closeSurvey: vi.fn(),
preloadSurveysScript: vi.fn(),
}));
describe("setup.ts", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;

View File

@@ -67,8 +67,6 @@ describe("widget-file", () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
// @ts-expect-error -- cleaning up mock
delete window.formbricksSurveys;
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
@@ -466,214 +464,6 @@ describe("widget-file", () => {
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
const scriptLoadMockConfig = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
// Helper to get the script element passed to document.head.appendChild
const getAppendedScript = (): Record<string, unknown> => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- accessing mock for test assertions
const appendChildMock = vi.mocked(document.head.appendChild);
for (const call of appendChildMock.mock.calls) {
const el = call[0] as unknown as Record<string, unknown>;
if (typeof el.src === "string" && el.src.includes("surveys.umd.cjs")) {
return el;
}
}
throw new Error("No script element for surveys.umd.cjs was appended to document.head");
};
beforeEach(() => {
// Reset mock return values that may have been overridden by previous tests
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
});
// Test onerror first so surveysLoadPromise is reset to null for subsequent tests
test("rejects when script fails to load (onerror) and allows retry", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
expect(scriptEl.src).toBe("https://fake.app/js/surveys.umd.cjs");
expect(scriptEl.async).toBe(true);
// Simulate network error
(scriptEl.onerror as (error: unknown) => void)("Network error");
// renderWidget catches the error internally — it resolves, not rejects
await renderPromise;
expect(consoleSpy).toHaveBeenCalledWith("Failed to load Formbricks Surveys library:", "Network error");
consoleSpy.mockRestore();
});
test("rejects when script loads but surveys global never becomes available (timeout)", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.useFakeTimers();
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
// Script loaded but window.formbricksSurveys is never set
(scriptEl.onload as () => void)();
// Advance past the 10s timeout (polls every 200ms)
await vi.advanceTimersByTimeAsync(10001);
await renderPromise;
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to load Formbricks Surveys library:",
expect.any(Error)
);
vi.useRealTimers();
consoleSpy.mockRestore();
});
test("resolves after polling when surveys global becomes available and applies stored nonce", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// Set nonce before surveys load to test nonce application
window.__formbricksNonce = "test-nonce-123";
vi.useFakeTimers();
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
// Simulate script loaded
(scriptEl.onload as () => void)();
// Set the global after script "loads" — simulates browser finishing execution
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
// Advance one polling interval for waitForSurveysGlobal to find it
await vi.advanceTimersByTimeAsync(200);
await renderPromise;
// Run remaining timers for survey.delay setTimeout
vi.runAllTimers();
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
appUrl: "https://fake.app",
environmentId: "env_123",
contactId: "contact_abc",
})
);
vi.useRealTimers();
delete window.__formbricksNonce;
});
test("deduplicates concurrent calls (returns cached promise)", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// After the previous successful test, surveysLoadPromise holds a resolved promise.
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
// should reuse the cached promise rather than creating a new script element.
// @ts-expect-error -- cleaning up mock to force dedup path
delete window.formbricksSurveys;
const appendChildSpy = vi.spyOn(document.head, "appendChild");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// No new script element should have been appended (dedup via early return or cached promise)
const scriptAppendCalls = appendChildSpy.mock.calls.filter((call: unknown[]) => {
const el = call[0] as Record<string, unknown> | undefined;
return typeof el?.src === "string" && el.src.includes("surveys.umd.cjs");
});
expect(scriptAppendCalls.length).toBe(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
test("preloadSurveysScript adds a preload link and deduplicates subsequent calls", () => {
const createElementSpy = vi.spyOn(document, "createElement");
const appendChildSpy = vi.spyOn(document.head, "appendChild");
widget.preloadSurveysScript("https://fake.app");
expect(createElementSpy).toHaveBeenCalledWith("link");
expect(appendChildSpy).toHaveBeenCalledTimes(1);
const linkEl = createElementSpy.mock.results[0].value as Record<string, string>;
expect(linkEl.rel).toBe("preload");
expect(linkEl.as).toBe("script");
expect(linkEl.href).toBe("https://fake.app/js/surveys.umd.cjs");
// Second call should be a no-op (deduplication)
widget.preloadSurveysScript("https://fake.app");
expect(appendChildSpy).toHaveBeenCalledTimes(1);
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);

View File

@@ -106,15 +106,7 @@ export const renderWidget = async (
const overlay = projectOverwrites.overlay ?? project.overlay;
const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding;
let formbricksSurveys: TFormbricksSurveys;
try {
formbricksSurveys = await loadFormbricksSurveysExternally();
} catch (error) {
logger.error(`Failed to load surveys library: ${String(error)}`);
setIsSurveyRunning(false);
return;
}
const formbricksSurveys = await loadFormbricksSurveysExternally();
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
@@ -227,87 +219,30 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
const SURVEYS_POLL_INTERVAL_MS = 200;
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const check = (): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
// Apply stored nonce if it was set before surveys package loaded
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
return;
}
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
reject(new Error("Formbricks Surveys library did not become available within timeout"));
return;
}
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
};
check();
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
};
document.head.appendChild(script);
}
});
};
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
return Promise.resolve(globalThis.window.formbricksSurveys);
}
if (surveysLoadPromise) {
return surveysLoadPromise;
}
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
const config = Config.getInstance();
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
waitForSurveysGlobal()
.then(resolve)
.catch((error: unknown) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
});
};
script.onerror = (error) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
};
document.head.appendChild(script);
});
return surveysLoadPromise;
};
let isPreloaded = false;
export const preloadSurveysScript = (appUrl: string): void => {
// Don't preload if already loaded or already preloading
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) return;
if (isPreloaded) return;
isPreloaded = true;
const link = document.createElement("link");
link.rel = "preload";
link.as = "script";
link.href = `${appUrl}/js/surveys.umd.cjs`;
document.head.appendChild(link);
};