From 666a79044fec6d445c43ae37306f1cc422e23d43 Mon Sep 17 00:00:00 2001
From: Matti Nannt
Date: Fri, 12 Dec 2025 05:05:25 +0100
Subject: [PATCH 01/24] fix: skip instance ID in license check during E2E tests
(#6968)
Co-authored-by: pandeymangg
---
.github/workflows/e2e.yml | 4 ++--
.../modules/ee/license-check/lib/license.ts | 23 +++++++++++++------
2 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index ebdfa42909..ada2c86f84 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -114,7 +114,7 @@ jobs:
- name: Start MinIO Server
run: |
set -euo pipefail
-
+
# Start MinIO server in background
docker run -d \
--name minio-server \
@@ -124,7 +124,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-
+
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts
index 393f2d5f76..cdd14b2034 100644
--- a/apps/web/modules/ee/license-check/lib/license.ts
+++ b/apps/web/modules/ee/license-check/lib/license.ts
@@ -7,6 +7,7 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
+import { E2E_TESTING } from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceId } from "@/lib/instance";
@@ -262,7 +263,9 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise controller.abort(), CONFIG.API.TIMEOUT_MS);
+ const payload: Record = {
+ licenseKey: env.ENTERPRISE_LICENSE_KEY,
+ usage: { responseCount },
+ };
+
+ if (instanceId) {
+ payload.instanceId = instanceId;
+ }
+
const res = await fetch(CONFIG.API.ENDPOINT, {
- body: JSON.stringify({
- licenseKey: env.ENTERPRISE_LICENSE_KEY,
- usage: { responseCount },
- instanceId,
- }),
+ body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
From 2d7b99ba262bc6f547c5c4e37f77e1ab26318c1a Mon Sep 17 00:00:00 2001
From: Johannes <72809645+jobenjada@users.noreply.github.com>
Date: Thu, 11 Dec 2025 21:01:48 -0800
Subject: [PATCH 02/24] feat: allow team admins to invite members to their own
teams (#6891)
Co-authored-by: pandeymangg
---
.../components/organization-breadcrumb.tsx | 2 +-
.../components/OrganizationSettingsNavbar.tsx | 2 +-
apps/web/i18n.lock | 10 +-
apps/web/locales/de-DE.json | 10 +-
apps/web/locales/en-US.json | 13 +-
apps/web/locales/es-ES.json | 10 +-
apps/web/locales/fr-FR.json | 10 +-
apps/web/locales/ja-JP.json | 10 +-
apps/web/locales/nl-NL.json | 10 +-
apps/web/locales/pt-BR.json | 10 +-
apps/web/locales/pt-PT.json | 10 +-
apps/web/locales/ro-RO.json | 10 +-
apps/web/locales/sv-SE.json | 8 +-
apps/web/locales/zh-Hans-CN.json | 10 +-
apps/web/locales/zh-Hant-TW.json | 10 +-
.../auth/signup/lib/__tests__/team.test.ts | 66 ++++++-
apps/web/modules/auth/signup/lib/team.ts | 26 +--
.../components/add-member-role.tsx | 2 +-
apps/web/modules/ee/teams/lib/roles.test.ts | 50 +++++-
apps/web/modules/ee/teams/lib/roles.ts | 28 +++
.../project-teams/components/access-table.tsx | 2 +-
.../project-teams/components/access-view.tsx | 5 +-
.../project-teams/components/manage-team.tsx | 21 +--
.../modules/ee/teams/project-teams/page.tsx | 6 +-
.../team-settings/team-settings-modal.tsx | 167 +++++++++++-------
.../organization/settings/teams/actions.ts | 90 ++++++++--
.../edit-memberships/organization-actions.tsx | 27 ++-
.../invite-member/individual-invite-tab.tsx | 102 +++++++----
.../invite-member/invite-member-modal.tsx | 38 ++--
.../teams/components/members-view.tsx | 7 +
.../organization/settings/teams/page.tsx | 13 +-
.../link/components/link-survey-wrapper.tsx | 4 +-
.../ui/components/client-logo/index.tsx | 7 +-
.../modules/ui/components/select/index.tsx | 2 +-
apps/web/playwright/organization.spec.ts | 6 +-
pnpm-lock.yaml | 1 +
36 files changed, 588 insertions(+), 217 deletions(-)
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx
index dd7fd851bb..64cffa3d19 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx
@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
- label: t("common.teams"),
+ label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
index 65a8ca0429..f43b9014ad 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
},
{
id: "teams",
- label: t("common.teams"),
+ label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index e98cff6619..46a9532cff 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -234,6 +234,7 @@ checksums:
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
+ common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
@@ -313,6 +314,7 @@ checksums:
common/read_docs: 426ba960bfedf186a878b7467867f9d2
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
+ common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
@@ -380,7 +382,8 @@ checksums:
common/team_access: 45c6232c71b760eaa33b932dabab4c1c
common/team_id: 134e32d6f7184577a46b2fd83e85e532
common/team_name: 549d949de4b9adad4afd6427a60a329e
- common/teams: a2fbdec69342366a2b6033d119aa279a
+ common/team_role: 66db395781aef64ef3791417b3b67c0b
+ common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
@@ -840,7 +843,6 @@ checksums:
environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553
environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad
environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d
- environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
@@ -1086,13 +1088,17 @@ checksums:
environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f
+ environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18
environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12
+ environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf
environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
+ environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
+ environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca
environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index f6f5f6f022..53a271ecfd 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -261,6 +261,7 @@
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
+ "members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
+ "remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
@@ -407,7 +409,8 @@
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
- "teams": "Zugriffskontrolle",
+ "team_role": "Team-Rolle",
+ "teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Teams verwalten",
"no_teams_found": "Keine Teams gefunden",
- "only_organization_owners_and_managers_can_manage_teams": "Nur Organisationsinhaber und -manager können Teams verwalten.",
"permission": "Berechtigung",
"team_name": "Teamname",
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
@@ -1167,13 +1169,17 @@
"manage_team": "Team verwalten",
"manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.",
"manager_role_description": "Manager können auf alle Projekte zugreifen und Mitglieder hinzufügen und entfernen.",
+ "member": "Mitglied",
"member_role_description": "Mitglieder können in ausgewählten Projekten arbeiten.",
"member_role_info_message": "Um neuen Mitgliedern Zugriff auf ein Projekt zu geben, füge sie bitte unten einem Team hinzu. Mit Teams kannst du steuern, wer auf welches Projekt zugreifen kann.",
+ "organization_role": "Organisationsrolle",
"owner_role_description": "Besitzer haben die volle Kontrolle über die Organisation.",
"please_fill_all_member_fields": "Bitte fülle alle Felder aus, um ein neues Mitglied hinzuzufügen.",
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
+ "select_member": "Mitglied auswählen",
+ "select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index ee5fa83ee8..5893d65434 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Member",
"members": "Members",
+ "members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
- "read_docs": "Read Docs",
+ "read_docs": "Read docs",
"recipients": "Recipients",
"remove": "Remove",
+ "remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
@@ -349,7 +351,6 @@
"responses": "Responses",
"restart": "Restart",
"role": "Role",
- "role_organization": "Role (Organization)",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
@@ -407,7 +408,8 @@
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
- "teams": "Access Control",
+ "team_role": "Team role",
+ "teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
@@ -903,7 +905,6 @@
"teams": {
"manage_teams": "Manage teams",
"no_teams_found": "No teams found",
- "only_organization_owners_and_managers_can_manage_teams": "Only organization owners and managers can manage teams.",
"permission": "Permission",
"team_name": "Team Name",
"team_settings_description": "See which teams can access this project."
@@ -1167,13 +1168,17 @@
"manage_team": "Manage team",
"manage_team_disabled": "Only organization owners, managers and team admins can manage teams.",
"manager_role_description": "Managers can access all projects and add and remove members.",
+ "member": "Member",
"member_role_description": "Members can work in selected projects.",
"member_role_info_message": "To give new members access to a project, please add them to a Team below. With Teams you can manage who has access to which project.",
+ "organization_role": "Organization role",
"owner_role_description": "Owners have full control over the organization.",
"please_fill_all_member_fields": "Please fill all the fields to add a new member.",
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
+ "select_member": "Select member",
+ "select_project": "Select project",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully.",
"team_deleted_successfully": "Team deleted successfully.",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 42544916e9..2b49aa2a5a 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
+ "members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"remove": "Eliminar",
+ "remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
@@ -407,7 +409,8 @@
"team_access": "Acceso de equipo",
"team_id": "ID de equipo",
"team_name": "Nombre del equipo",
- "teams": "Control de acceso",
+ "team_role": "Rol del equipo",
+ "teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gestionar equipos",
"no_teams_found": "No se han encontrado equipos",
- "only_organization_owners_and_managers_can_manage_teams": "Solo los propietarios y gestores de la organización pueden gestionar equipos.",
"permission": "Permiso",
"team_name": "Nombre del equipo",
"team_settings_description": "Consulta qué equipos pueden acceder a este proyecto."
@@ -1167,13 +1169,17 @@
"manage_team": "Gestionar equipo",
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
+ "member": "Miembro",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
+ "organization_role": "Rol en la organización",
"owner_role_description": "Los propietarios tienen control total sobre la organización.",
"please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.",
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
+ "select_member": "Seleccionar miembro",
+ "select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 46cd55662e..8fef157bb1 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -261,6 +261,7 @@
"maximum": "Max",
"member": "Membre",
"members": "Membres",
+ "members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
@@ -340,6 +341,7 @@
"read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer",
+ "remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
@@ -407,7 +409,8 @@
"team_access": "Accès",
"team_id": "Identifiant de l'équipe",
"team_name": "Nom de l'équipe",
- "teams": "Contrôle d'accès",
+ "team_role": "Rôle dans l'équipe",
+ "teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gérer les équipes",
"no_teams_found": "Aucune équipe trouvée",
- "only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.",
"permission": "Permission",
"team_name": "Nom de l'équipe",
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
@@ -1167,13 +1169,17 @@
"manage_team": "Gérer l'équipe",
"manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.",
"manager_role_description": "Les gestionnaires peuvent accéder à tous les projets et ajouter et supprimer des membres.",
+ "member": "Membre",
"member_role_description": "Les membres peuvent travailler sur des projets sélectionnés.",
"member_role_info_message": "Pour donner accès à un projet aux nouveaux membres, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel projet.",
+ "organization_role": "Rôle dans l'organisation",
"owner_role_description": "Les propriétaires ont un contrôle total sur l'organisation.",
"please_fill_all_member_fields": "Veuillez remplir tous les champs pour ajouter un nouveau membre.",
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
+ "select_member": "Sélectionner membre",
+ "select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index ee26fb6f0d..3be1eae10d 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -261,6 +261,7 @@
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
+ "members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
@@ -340,6 +341,7 @@
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"remove": "削除",
+ "remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
@@ -407,7 +409,8 @@
"team_access": "チームアクセス",
"team_id": "チームID",
"team_name": "チーム名",
- "teams": "アクセス制御",
+ "team_role": "チームの役割",
+ "teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "チームを管理",
"no_teams_found": "チームが見つかりません",
- "only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。",
"permission": "権限",
"team_name": "チーム名",
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
@@ -1167,13 +1169,17 @@
"manage_team": "チームを管理",
"manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。",
"manager_role_description": "管理者はすべてのプロジェクトにアクセスでき、メンバーを追加および削除できます。",
+ "member": "メンバー",
"member_role_description": "メンバーは選択されたプロジェクトで作業できます。",
"member_role_info_message": "新しいメンバーにプロジェクトへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのプロジェクトにアクセスできるかを管理できます。",
+ "organization_role": "組織の役割",
"owner_role_description": "オーナーは組織を完全に制御できます。",
"please_fill_all_member_fields": "新しいメンバーを追加するには、すべてのフィールドを記入してください。",
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
+ "select_member": "メンバーを選択",
+ "select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index 5f9d7b36e8..eeb63ca0f5 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -261,6 +261,7 @@
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
+ "members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Lees Documenten",
"recipients": "Ontvangers",
"remove": "Verwijderen",
+ "remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
@@ -407,7 +409,8 @@
"team_access": "Teamtoegang",
"team_id": "Team-ID",
"team_name": "Teamnaam",
- "teams": "Toegangscontrole",
+ "team_role": "Teamrol",
+ "teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Beheer teams",
"no_teams_found": "Geen teams gevonden",
- "only_organization_owners_and_managers_can_manage_teams": "Alleen eigenaren en managers van organisaties kunnen teams beheren.",
"permission": "Toestemming",
"team_name": "Teamnaam",
"team_settings_description": "Bekijk welke teams toegang hebben tot dit project."
@@ -1167,13 +1169,17 @@
"manage_team": "Beheer team",
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
+ "member": "Lid",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
+ "organization_role": "Organisatierol",
"owner_role_description": "Eigenaars hebben volledige controle over de organisatie.",
"please_fill_all_member_fields": "Vul alle velden in om een nieuw lid toe te voegen.",
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
+ "select_member": "Selecteer lid",
+ "select_project": "Selecteer project",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 89da514535..effc6967ed 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
+ "members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Ler Documentação",
"recipients": "Destinatários",
"remove": "remover",
+ "remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
@@ -407,7 +409,8 @@
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
- "teams": "Controle de Acesso",
+ "team_role": "Função na equipe",
+ "teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gerenciar Equipes",
"no_teams_found": "Nenhuma equipe encontrada",
- "only_organization_owners_and_managers_can_manage_teams": "Apenas proprietários e gerentes da organização podem gerenciar equipes.",
"permission": "Permissão",
"team_name": "Nome da equipe",
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
@@ -1167,13 +1169,17 @@
"manage_team": "Gerenciar equipe",
"manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.",
"manager_role_description": "Os gerentes podem acessar todos os projetos e adicionar e remover membros.",
+ "member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor, adicione-os a uma equipe abaixo. Com equipes, você pode gerenciar quem tem acesso a qual projeto.",
+ "organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controle total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
+ "select_member": "Selecionar membro",
+ "select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 466315bc32..36648ae707 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
+ "members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover",
+ "remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
@@ -407,7 +409,8 @@
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
- "teams": "Controlo de Acesso",
+ "team_role": "Função na equipa",
+ "teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gerir equipas",
"no_teams_found": "Nenhuma equipa encontrada",
- "only_organization_owners_and_managers_can_manage_teams": "Apenas os proprietários e gestores da organização podem gerir equipas.",
"permission": "Permissão",
"team_name": "Nome da Equipa",
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
@@ -1167,13 +1169,17 @@
"manage_team": "Gerir equipa",
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.",
+ "member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.",
+ "organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controlo total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
+ "select_member": "Selecionar membro",
+ "select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 7faa1e952b..a2e611096e 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
+ "members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
@@ -340,6 +341,7 @@
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"remove": "Șterge",
+ "remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
@@ -407,7 +409,8 @@
"team_access": "Acces echipă",
"team_id": "ID echipă",
"team_name": "Nume echipă",
- "teams": "Control acces",
+ "team_role": "Rol în echipă",
+ "teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gestionați echipele",
"no_teams_found": "Nicio echipă găsită",
- "only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.",
"permission": "Permisiune",
"team_name": "Nume echipă",
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
@@ -1167,13 +1169,17 @@
"manage_team": "Gestionați echipa",
"manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.",
"manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.",
+ "member": "Membru",
"member_role_description": "Membrii pot lucra în proiectele selectate.",
"member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.",
+ "organization_role": "Rol în organizație",
"owner_role_description": "Proprietarii au control total asupra organizației.",
"please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.",
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
+ "select_member": "Selectează membrul",
+ "select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index d46be0c3fb..268221f15f 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Medlem",
"members": "Medlemmar",
+ "members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Läs dokumentation",
"recipients": "Mottagare",
"remove": "Ta bort",
+ "remove_from_team": "Ta bort från teamet",
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
"report_survey": "Rapportera enkät",
"request_pricing": "Begär prissättning",
@@ -407,6 +409,7 @@
"team_access": "Teamåtkomst",
"team_id": "Team-ID",
"team_name": "Teamnamn",
+ "team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"teams_not_found": "Team hittades inte",
"text": "Text",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Hantera team",
"no_teams_found": "Inga team hittades",
- "only_organization_owners_and_managers_can_manage_teams": "Endast organisationsägare och administratörer kan hantera team.",
"permission": "Behörighet",
"team_name": "Teamnamn",
"team_settings_description": "Se vilka team som kan komma åt detta projekt."
@@ -1167,13 +1169,17 @@
"manage_team": "Hantera team",
"manage_team_disabled": "Endast organisationsägare, administratörer och teamadministratörer kan hantera team.",
"manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.",
+ "member": "Medlem",
"member_role_description": "Medlemmar kan arbeta i valda projekt.",
"member_role_info_message": "För att ge nya medlemmar åtkomst till ett projekt, vänligen lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilket projekt.",
+ "organization_role": "Organisationsroll",
"owner_role_description": "Ägare har full kontroll över organisationen.",
"please_fill_all_member_fields": "Vänligen fyll i alla fält för att lägga till en ny medlem.",
"please_fill_all_project_fields": "Vänligen fyll i alla fält för att lägga till ett nytt projekt.",
"read": "Läs",
"read_write": "Läs och skriv",
+ "select_member": "Välj medlem",
+ "select_project": "Välj projekt",
"team_admin": "Teamadministratör",
"team_created_successfully": "Team skapat.",
"team_deleted_successfully": "Team borttaget.",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index f8ea6ed123..eaa8632fcf 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成员",
"members": "成员",
+ "members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
@@ -340,6 +341,7 @@
"read_docs": "阅读 文档",
"recipients": "收件人",
"remove": "移除",
+ "remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
@@ -407,7 +409,8 @@
"team_access": "团队 访问",
"team_id": "团队 ID",
"team_name": "团队 名称",
- "teams": "访问控制",
+ "team_role": "团队角色",
+ "teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "管理 团队",
"no_teams_found": "未找到 团队",
- "only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。",
"permission": "权限",
"team_name": "团队名称",
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
@@ -1167,13 +1169,17 @@
"manage_team": "管理团队",
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
+ "member": "成员",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。",
+ "organization_role": "组织角色",
"owner_role_description": "所有者拥有对组织的完全控制权。",
"please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。",
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
+ "select_member": "选择成员",
+ "select_project": "选择项目",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 19fbd6e996..ebdbbf045c 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成員",
"members": "成員",
+ "members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
@@ -340,6 +341,7 @@
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
+ "remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
@@ -407,7 +409,8 @@
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
- "teams": "存取控制",
+ "team_role": "團隊角色",
+ "teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "管理團隊",
"no_teams_found": "找不到團隊",
- "only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。",
"permission": "權限",
"team_name": "團隊名稱",
"team_settings_description": "查看哪些團隊可以存取此專案。"
@@ -1167,13 +1169,17 @@
"manage_team": "管理團隊",
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理員可以存取所有專案,並新增和移除成員。",
+ "member": "成員",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
+ "organization_role": "組織角色",
"owner_role_description": "擁有者對組織具有完全控制權。",
"please_fill_all_member_fields": "請填寫所有欄位以新增新成員。",
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
+ "select_member": "選擇成員",
+ "select_project": "選擇專案",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",
diff --git a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts
index 026c1e4094..d8821e31fe 100644
--- a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts
+++ b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts
@@ -3,7 +3,7 @@ import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
-import { createTeamMembership } from "../team";
+import { createTeamMembership, getTeamProjectIds } from "../team";
// Setup all mocks
const setupMocks = () => {
@@ -31,6 +31,7 @@ const setupMocks = () => {
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
+ warn: vi.fn(),
},
}));
@@ -55,7 +56,7 @@ describe("Team Management", () => {
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
- vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
@@ -90,7 +91,7 @@ describe("Team Management", () => {
role: "member" as OrganizationRole,
};
- vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
@@ -110,11 +111,68 @@ describe("Team Management", () => {
describe("error handling", () => {
test("throws error when database operation fails", async () => {
- vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
+
+ describe("when team does not exist", () => {
+ test("skips membership creation and continues to next team", async () => {
+ const inviteWithMultipleTeams: CreateMembershipInvite = {
+ ...MOCK_INVITE,
+ teamIds: ["non-existent-team", MOCK_IDS.teamId],
+ };
+
+ vi.mocked(prisma.team.findUnique)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(MOCK_TEAM as unknown as any);
+ vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
+
+ await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId);
+
+ expect(prisma.team.findUnique).toHaveBeenCalledTimes(2);
+ expect(prisma.teamUser.create).toHaveBeenCalledTimes(1);
+ expect(prisma.teamUser.create).toHaveBeenCalledWith({
+ data: {
+ teamId: MOCK_IDS.teamId,
+ userId: MOCK_IDS.userId,
+ role: "admin",
+ },
+ });
+ });
+ });
+ });
+
+ describe("getTeamProjectIds", () => {
+ test("returns team with projectTeams when team exists", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
+
+ const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
+
+ expect(result).toEqual(MOCK_TEAM);
+ expect(prisma.team.findUnique).toHaveBeenCalledWith({
+ where: {
+ id: MOCK_IDS.teamId,
+ organizationId: MOCK_IDS.organizationId,
+ },
+ select: {
+ projectTeams: {
+ select: {
+ projectId: true,
+ },
+ },
+ },
+ });
+ });
+
+ test("returns null when team does not exist", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
+
+ const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
+
+ expect(result).toBeNull();
+ });
});
});
diff --git a/apps/web/modules/auth/signup/lib/team.ts b/apps/web/modules/auth/signup/lib/team.ts
index 1fd81fe294..7f1324a9a4 100644
--- a/apps/web/modules/auth/signup/lib/team.ts
+++ b/apps/web/modules/auth/signup/lib/team.ts
@@ -18,15 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
- if (team) {
- await prisma.teamUser.create({
- data: {
- teamId,
- userId,
- role: isOwnerOrManager ? "admin" : "contributor",
- },
- });
+ if (!team) {
+ logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
+ continue;
}
+
+ await prisma.teamUser.create({
+ data: {
+ teamId,
+ userId,
+ role: isOwnerOrManager ? "admin" : "contributor",
+ },
+ });
}
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
@@ -39,7 +42,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
};
export const getTeamProjectIds = reactCache(
- async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => {
+ async (
+ teamId: string,
+ organizationId: string
+ ): Promise<{ projectTeams: { projectId: string }[] } | null> => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
@@ -55,7 +61,7 @@ export const getTeamProjectIds = reactCache(
});
if (!team) {
- throw new Error("Team not found");
+ return null;
}
return team;
diff --git a/apps/web/modules/ee/role-management/components/add-member-role.tsx b/apps/web/modules/ee/role-management/components/add-member-role.tsx
index 2b5ed799d1..00400f3b5d 100644
--- a/apps/web/modules/ee/role-management/components/add-member-role.tsx
+++ b/apps/web/modules/ee/role-management/components/add-member-role.tsx
@@ -60,7 +60,7 @@ export function AddMemberRole({
name="role"
render={({ field: { onChange, value } }) => (
-
+
);
@@ -360,7 +382,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_members_added")
}>
)}
- setLeaveOrganizationModalOpen(false)}>
+ setIsLeaveOrganizationModalOpen(false)}>
{t("common.cancel")}
{
const ZFormSchema = z.object({
name: ZUserName,
email: z.string().min(1, { message: "Email is required" }).email({ message: "Invalid email" }),
role: ZOrganizationRole,
- teamIds: z.array(z.string()),
+ teamIds: showTeamAdminRestrictions
+ ? z.array(ZId).min(1, { message: "Team admins must select at least one team" })
+ : z.array(ZId),
});
const router = useRouter();
type TFormData = z.infer;
const { t } = useTranslation();
+
+ // Determine default role based on permissions
+ let defaultRole: TOrganizationRole = "owner";
+ if (showTeamAdminRestrictions || isAccessControlAllowed) {
+ defaultRole = "member";
+ }
+
const form = useForm({
resolver: zodResolver(ZFormSchema),
defaultValues: {
- role: isAccessControlAllowed ? "member" : "owner",
+ role: defaultRole,
teamIds: [],
},
});
@@ -104,43 +116,61 @@ export const IndividualInviteTab = ({
{errors.email && {errors.email.message}
}
-
- {watch("role") === "member" && (
-
- {t("environments.settings.teams.member_role_info_message")}
-
+ {showTeamAdminRestrictions ? (
+
+
+
+
+ ) : (
+ <>
+
+ {watch("role") === "member" && (
+
+
+ {t("environments.settings.teams.member_role_info_message")}
+
+
+ )}
+ >
)}
{isAccessControlAllowed && (
- (
-
- {t("common.add_to_team")}
-
- field.onChange(val)}
- />
- {!teamOptions.length && (
-
- {t("environments.settings.teams.create_first_team_message")}
-
- )}
-
-
- )}
- />
+ <>
+ (
+
+ {t("common.add_to_team")}
+
+ field.onChange(val)}
+ />
+ {!teamOptions.length && (
+
+ {t("environments.settings.teams.create_first_team_message")}
+
+ )}
+
+ {errors.teamIds?.message}
+
+ )}
+ />
+
+
+
+
+ >
)}
{!isAccessControlAllowed && (
diff --git a/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx b/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx
index 04c97b4d9b..3ecc3330ad 100644
--- a/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx
+++ b/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx
@@ -26,6 +26,9 @@ interface InviteMemberModalProps {
environmentId: string;
membershipRole?: TOrganizationRole;
isStorageConfigured: boolean;
+ isOwnerOrManager: boolean;
+ isTeamAdmin: boolean;
+ userAdminTeamIds?: string[];
}
export const InviteMemberModal = ({
@@ -38,11 +41,21 @@ export const InviteMemberModal = ({
environmentId,
membershipRole,
isStorageConfigured,
+ isOwnerOrManager,
+ isTeamAdmin,
+ userAdminTeamIds,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
const { t } = useTranslation();
+ const showTeamAdminRestrictions = !isOwnerOrManager && isTeamAdmin;
+
+ const filteredTeams =
+ showTeamAdminRestrictions && userAdminTeamIds
+ ? teams.filter((t) => userAdminTeamIds.includes(t.id))
+ : teams;
+
const tabs = {
individual: (
),
bulk: (
@@ -75,16 +89,18 @@ export const InviteMemberModal = ({
- setType(inviteType)}
- defaultSelected={type}
- />
- {tabs[type]}
+ {!showTeamAdminRestrictions && (
+ setType(inviteType)}
+ defaultSelected={type}
+ />
+ )}
+ {showTeamAdminRestrictions ? tabs.individual : tabs[type]}
diff --git a/apps/web/modules/organization/settings/teams/components/members-view.tsx b/apps/web/modules/organization/settings/teams/components/members-view.tsx
index e745271966..cbae9e3e1c 100644
--- a/apps/web/modules/organization/settings/teams/components/members-view.tsx
+++ b/apps/web/modules/organization/settings/teams/components/members-view.tsx
@@ -5,6 +5,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
+import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships";
@@ -45,6 +46,10 @@ export const MembersView = async ({
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
+ // Fetch admin teams if they're a team admin
+ const userAdminTeamIds = await getTeamsWhereUserIsAdmin(currentUserId, organization.id);
+ const isTeamAdminUser = userAdminTeamIds.length > 0;
+
let teams: TOrganizationTeam[] = [];
if (isAccessControlAllowed) {
@@ -69,6 +74,8 @@ export const MembersView = async ({
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
+ isTeamAdmin={isTeamAdminUser}
+ userAdminTeamIds={userAdminTeamIds}
/>
)}
diff --git a/apps/web/modules/organization/settings/teams/page.tsx b/apps/web/modules/organization/settings/teams/page.tsx
index 2d534b80c9..9a5a45dea3 100644
--- a/apps/web/modules/organization/settings/teams/page.tsx
+++ b/apps/web/modules/organization/settings/teams/page.tsx
@@ -3,6 +3,7 @@ import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constan
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
+import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
@@ -16,11 +17,21 @@ export const TeamsPage = async (props) => {
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
- const hasUserManagementAccess = getUserManagementAccess(
+
+ // Check if user has standard user management access (owner/manager)
+ const hasStandardUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
+ // Also check if user is a team admin (they get limited user management for invites)
+ const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id);
+ const isTeamAdminUser = userAdminTeamIds.length > 0;
+
+ // Allow user management UI if they're owner/manager OR team admin (when access control is enabled)
+ const hasUserManagementAccess =
+ hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
+
return (
diff --git a/apps/web/modules/survey/link/components/link-survey-wrapper.tsx b/apps/web/modules/survey/link/components/link-survey-wrapper.tsx
index 29e52977a8..d5043d4862 100644
--- a/apps/web/modules/survey/link/components/link-survey-wrapper.tsx
+++ b/apps/web/modules/survey/link/components/link-survey-wrapper.tsx
@@ -79,7 +79,9 @@ export const LinkSurveyWrapper = ({
styling={styling}
onBackgroundLoaded={handleBackgroundLoaded}>
- {!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) &&
}
+ {!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
+
+ )}
{isPreview && (
diff --git a/apps/web/modules/ui/components/client-logo/index.tsx b/apps/web/modules/ui/components/client-logo/index.tsx
index 51f768a94a..8b84264d47 100644
--- a/apps/web/modules/ui/components/client-logo/index.tsx
+++ b/apps/web/modules/ui/components/client-logo/index.tsx
@@ -15,7 +15,12 @@ interface ClientLogoProps {
previewSurvey?: boolean;
}
-export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurvey = false }: ClientLogoProps) => {
+export const ClientLogo = ({
+ environmentId,
+ projectLogo,
+ surveyLogo,
+ previewSurvey = false,
+}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
diff --git a/apps/web/modules/ui/components/select/index.tsx b/apps/web/modules/ui/components/select/index.tsx
index 3be9861226..b1079a0418 100644
--- a/apps/web/modules/ui/components/select/index.tsx
+++ b/apps/web/modules/ui/components/select/index.tsx
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
diff --git a/apps/web/playwright/organization.spec.ts b/apps/web/playwright/organization.spec.ts
index 18f99c2321..3d50b76613 100644
--- a/apps/web/playwright/organization.spec.ts
+++ b/apps/web/playwright/organization.spec.ts
@@ -20,7 +20,7 @@ test.describe("Invite, accept and remove organization member", async () => {
await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" });
- await page.getByRole("link", { name: "Access Control" }).click();
+ await page.getByRole("link", { name: "Members & Teams" }).click();
// Add member button
await expect(page.getByRole("button", { name: "Invite member" })).toBeVisible();
@@ -131,8 +131,8 @@ test.describe("Create, update and delete team", async () => {
await page.waitForURL(/\/environments\/[^/]+\/settings\/general/);
await page.waitForTimeout(2000);
- await expect(page.getByText("Access Control")).toBeVisible();
- await page.getByText("Access Control").click();
+ await expect(page.getByText("Members & Teams")).toBeVisible();
+ await page.getByText("Members & Teams").click();
await page.waitForURL(/\/environments\/[^/]+\/settings\/teams/);
await expect(page.getByRole("button", { name: "Create new team" })).toBeVisible();
await page.getByRole("button", { name: "Create new team" }).click();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e3d3f5677f..bc83c3f7ca 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7754,6 +7754,7 @@ packages:
next@15.5.7:
resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
+ deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
From b96f0e67c59af6a42e89158693e878b44045b764 Mon Sep 17 00:00:00 2001
From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Date: Fri, 12 Dec 2025 10:52:48 +0530
Subject: [PATCH 03/24] fix: preserve attribute key casing during CSV contact
upload (#6958)
Co-authored-by: Johannes
---
.../modules/ee/contacts/lib/contacts.test.ts | 57 ++----
apps/web/modules/ee/contacts/lib/contacts.ts | 186 ++++++++----------
2 files changed, 99 insertions(+), 144 deletions(-)
diff --git a/apps/web/modules/ee/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/lib/contacts.test.ts
index 0aa8fd0bd1..9486e74a76 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.test.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.test.ts
@@ -458,21 +458,15 @@ describe("Contacts Lib", () => {
attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }],
};
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([existingContact as any])
- .mockResolvedValueOnce([{ key: "email", id: "key-1" } as any])
- .mockResolvedValueOnce([
- { key: "userId", id: "key-2" },
- { key: "email", id: "key-1" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([{ key: "email", id: "key-1" }] as any)
.mockResolvedValueOnce([
- { key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
+ { key: "name", id: "key-3" },
] as any);
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
@@ -489,25 +483,15 @@ describe("Contacts Lib", () => {
],
};
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([existingContact as any])
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ .mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
@@ -525,25 +509,15 @@ describe("Contacts Lib", () => {
],
};
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([existingContact as any])
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ .mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
@@ -582,23 +556,16 @@ describe("Contacts Lib", () => {
test("creates missing attribute keys", async () => {
const attributeMap = { email: "email", userId: "userId" };
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
+ { key: "name", id: "key-3" },
] as any);
-
- vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 3 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "new-1",
environmentId: mockEnvironmentId,
diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts
index f1813da0f7..6fd1578017 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.ts
@@ -200,6 +200,50 @@ export const deleteContact = async (contactId: string): Promise
}
};
+// Shared include clause for contact queries
+const contactAttributesInclude = {
+ attributes: {
+ select: {
+ attributeKey: { select: { key: true } },
+ value: true,
+ },
+ },
+} satisfies Prisma.ContactInclude;
+
+// Helper to create attribute objects for Prisma create operations
+const createAttributeConnections = (record: Record, environmentId: string) =>
+ Object.entries(record).map(([key, value]) => ({
+ attributeKey: {
+ connect: { key_environmentId: { key, environmentId } },
+ },
+ value,
+ }));
+
+// Helper to handle userId conflicts when updating/overwriting contacts
+const resolveUserIdConflict = (
+ mappedRecord: Record,
+ existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] },
+ existingUserIds: { value: string; contactId: string }[]
+): Record => {
+ const existingUserId = existingUserIds.find(
+ (attr) => attr.value === mappedRecord.userId && attr.contactId !== existingContact.id
+ );
+
+ if (!existingUserId) {
+ return { ...mappedRecord };
+ }
+
+ const { userId: _userId, ...rest } = mappedRecord;
+ const existingContactUserId = existingContact.attributes.find(
+ (attr) => attr.attributeKey.key === "userId"
+ )?.value;
+
+ return {
+ ...rest,
+ ...(existingContactUserId && { userId: existingContactUserId }),
+ };
+};
+
export const createContactsFromCSV = async (
csvData: Record[],
environmentId: string,
@@ -287,22 +331,36 @@ export const createContactsFromCSV = async (
});
const attributeKeyMap = new Map();
+ // Map from lowercase key to actual DB key (for case-insensitive lookup)
+ const lowercaseToActualKeyMap = new Map();
+
existingAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
+ lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
- // Identify missing attribute keys (normalize keys to lowercase)
+ // Collect all unique CSV keys
const csvKeys = new Set();
csvData.forEach((record) => {
- Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
+ Object.keys(record).forEach((key) => csvKeys.add(key));
});
- const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
+ // Identify missing attribute keys (case-insensitive check)
+ const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
- // Create missing attribute keys
+ // Create missing attribute keys (use original CSV casing for new keys)
if (missingKeys.length > 0) {
+ // Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
+ const uniqueMissingKeys = new Map();
+ missingKeys.forEach((key) => {
+ const lowerKey = key.toLowerCase();
+ if (!uniqueMissingKeys.has(lowerKey)) {
+ uniqueMissingKeys.set(lowerKey, key);
+ }
+ });
+
await prisma.contactAttributeKey.createMany({
- data: missingKeys.map((key) => ({
+ data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: key,
environmentId,
@@ -310,10 +368,10 @@ export const createContactsFromCSV = async (
skipDuplicates: true,
});
- // Fetch and update the attributeKeyMap with new keys
+ // Fetch and update the maps with new keys
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
- key: { in: missingKeys },
+ key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true },
@@ -321,6 +379,7 @@ export const createContactsFromCSV = async (
newAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
+ lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
}
@@ -328,18 +387,23 @@ export const createContactsFromCSV = async (
// Process contacts in parallel
const contactPromises = csvData.map(async (record) => {
- // Normalize record keys to lowercase
- const normalizedRecord: Record = {};
+ // Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing)
+ const mappedRecord: Record = {};
Object.entries(record).forEach(([key, value]) => {
- normalizedRecord[key.toLowerCase()] = value;
+ const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
+ if (!actualKey) {
+ // This should never happen since we create missing keys above
+ throw new ValidationError(`Attribute key "${key}" not found in attribute key map`);
+ }
+ mappedRecord[actualKey] = value;
});
// Skip records without email
- if (!normalizedRecord.email) {
+ if (!mappedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
- const existingContact = emailToContactMap.get(normalizedRecord.email);
+ const existingContact = emailToContactMap.get(mappedRecord.email);
if (existingContact) {
// Handle duplicates based on duplicateContactsAction
@@ -348,25 +412,7 @@ export const createContactsFromCSV = async (
return null;
case "update": {
- // if the record has a userId, check if it already exists
- const existingUserId = existingUserIds.find(
- (attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
- );
- let recordToProcess = { ...normalizedRecord };
- if (existingUserId) {
- const { userid, ...rest } = recordToProcess;
-
- const existingContactUserId = existingContact.attributes.find(
- (attr) => attr.attributeKey.key === "userId"
- )?.value;
-
- recordToProcess = {
- ...rest,
- ...(existingContactUserId && {
- userId: existingContactUserId,
- }),
- };
- }
+ const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
where: {
@@ -383,7 +429,7 @@ export const createContactsFromCSV = async (
}));
// Update contact with upserted attributes
- const updatedContact = prisma.contact.update({
+ return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
@@ -391,98 +437,40 @@ export const createContactsFromCSV = async (
upsert: attributesToUpsert,
},
},
- include: {
- attributes: {
- select: {
- attributeKey: { select: { key: true } },
- value: true,
- },
- },
- },
+ include: contactAttributesInclude,
});
-
- return updatedContact;
}
case "overwrite": {
- // if the record has a userId, check if it already exists
- const existingUserId = existingUserIds.find(
- (attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
- );
- let recordToProcess = { ...normalizedRecord };
- if (existingUserId) {
- const { userid, ...rest } = recordToProcess;
- const existingContactUserId = existingContact.attributes.find(
- (attr) => attr.attributeKey.key === "userId"
- )?.value;
-
- recordToProcess = {
- ...rest,
- ...(existingContactUserId && {
- userId: existingContactUserId,
- }),
- };
- }
+ const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
// Overwrite by deleting existing attributes and creating new ones
await prisma.contactAttribute.deleteMany({
where: { contactId: existingContact.id },
});
- const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({
- attributeKey: {
- connect: { key_environmentId: { key, environmentId } },
- },
- value,
- }));
-
- const updatedContact = prisma.contact.update({
+ return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
- create: newAttributes,
- },
- },
- include: {
- attributes: {
- select: {
- attributeKey: { select: { key: true } },
- value: true,
- },
+ create: createAttributeConnections(recordToProcess, environmentId),
},
},
+ include: contactAttributesInclude,
});
-
- return updatedContact;
}
}
} else {
- // Create new contact
- const newAttributes = Object.entries(record).map(([key, value]) => ({
- attributeKey: {
- connect: { key_environmentId: { key, environmentId } },
- },
- value,
- }));
-
- const newContact = prisma.contact.create({
+ // Create new contact - use mappedRecord with proper DB key casing
+ return prisma.contact.create({
data: {
environmentId,
attributes: {
- create: newAttributes,
- },
- },
- include: {
- attributes: {
- select: {
- attributeKey: { select: { key: true } },
- value: true,
- },
+ create: createAttributeConnections(mappedRecord, environmentId),
},
},
+ include: contactAttributesInclude,
});
-
- return newContact;
}
});
From 42525a86a8c6686369ce2cae0c345b22535de096 Mon Sep 17 00:00:00 2001
From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Date: Fri, 12 Dec 2025 11:33:35 +0530
Subject: [PATCH 04/24] fix: close the survey on formbricks.logout (#6955)
---
packages/js-core/src/lib/common/constants.ts | 2 +-
packages/js-core/src/lib/common/setup.ts | 10 ++--------
packages/js-core/src/lib/common/tests/setup.test.ts | 3 ++-
packages/js-core/src/lib/survey/widget.ts | 3 +--
4 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/packages/js-core/src/lib/common/constants.ts b/packages/js-core/src/lib/common/constants.ts
index 236b34d4e6..9748e4ccab 100644
--- a/packages/js-core/src/lib/common/constants.ts
+++ b/packages/js-core/src/lib/common/constants.ts
@@ -1,5 +1,5 @@
export const JS_LOCAL_STORAGE_KEY = "formbricks-js";
export const LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
export const LEGACY_JS_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
-export const CONTAINER_ID = "formbricks-app-container";
+export const CONTAINER_ID = "formbricks-modal-container";
export const RECAPTCHA_SCRIPT_ID = "formbricks-recaptcha-script";
diff --git a/packages/js-core/src/lib/common/setup.ts b/packages/js-core/src/lib/common/setup.ts
index 27bc9da4fc..3a897f782f 100644
--- a/packages/js-core/src/lib/common/setup.ts
+++ b/packages/js-core/src/lib/common/setup.ts
@@ -7,7 +7,7 @@ import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { checkPageUrl } from "@/lib/survey/no-code-action";
-import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } 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 {
@@ -142,9 +142,6 @@ export const setup = async (
});
}
- logger.debug("Adding widget container to DOM");
- addWidgetContainer();
-
if (
existingConfig?.environment &&
existingConfig.environmentId === configInput.environmentId &&
@@ -344,10 +341,7 @@ export const tearDown = (): void => {
filteredSurveys,
});
- // remove container element from DOM
- removeWidgetContainer();
- addWidgetContainer();
- setIsSurveyRunning(false);
+ closeSurvey();
};
export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise => {
diff --git a/packages/js-core/src/lib/common/tests/setup.test.ts b/packages/js-core/src/lib/common/tests/setup.test.ts
index 5590c72828..a9d79da5f1 100644
--- a/packages/js-core/src/lib/common/tests/setup.test.ts
+++ b/packages/js-core/src/lib/common/tests/setup.test.ts
@@ -290,12 +290,13 @@ describe("setup.ts", () => {
test("resets user state to default", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
+ environment: { data: { surveys: [] } },
user: { data: { userId: "XYZ" } },
}),
update: vi.fn(),
};
- getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as Config);
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
tearDown();
diff --git a/packages/js-core/src/lib/survey/widget.ts b/packages/js-core/src/lib/survey/widget.ts
index d86ae4ca7a..6b524a3a1e 100644
--- a/packages/js-core/src/lib/survey/widget.ts
+++ b/packages/js-core/src/lib/survey/widget.ts
@@ -174,9 +174,8 @@ export const renderWidget = async (
export const closeSurvey = (): void => {
const config = Config.getInstance();
- // remove container element from DOM
+ // remove the survey modal container from DOM
removeWidgetContainer();
- addWidgetContainer();
const { environment, user } = config.get();
const filteredSurveys = filterSurveys(environment, user);
From 5dbf42fd6a519964f6c2b2458e1680a23a2dab38 Mon Sep 17 00:00:00 2001
From: Johannes <72809645+jobenjada@users.noreply.github.com>
Date: Thu, 11 Dec 2025 22:49:49 -0800
Subject: [PATCH 05/24] feat: add bulk edit for single-select and multi-select
options (#6951)
Co-authored-by: pandeymangg
---
apps/web/i18n.lock | 6 +
apps/web/locales/de-DE.json | 6 +
apps/web/locales/en-US.json | 6 +
apps/web/locales/es-ES.json | 6 +
apps/web/locales/fr-FR.json | 6 +
apps/web/locales/ja-JP.json | 6 +
apps/web/locales/nl-NL.json | 6 +
apps/web/locales/pt-BR.json | 6 +
apps/web/locales/pt-PT.json | 6 +
apps/web/locales/ro-RO.json | 6 +
apps/web/locales/sv-SE.json | 6 +
apps/web/locales/zh-Hans-CN.json | 6 +
apps/web/locales/zh-Hant-TW.json | 6 +
.../components/bulk-edit-options-modal.tsx | 195 ++++++++++++++++++
.../multiple-choice-element-form.tsx | 50 ++++-
15 files changed, 321 insertions(+), 2 deletions(-)
create mode 100644 apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 46a9532cff..f3462e2ee5 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -1182,6 +1182,10 @@ checksums:
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
+ environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb
+ environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb
+ environments/surveys/edit/bulk_edit_options: 74ebec7c53be729f33e38d7605b25815
+ environments/surveys/edit/bulk_edit_options_for: 986af3a8286f34c9e4ad7c74d3c65ada
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
@@ -1428,6 +1432,7 @@ checksums:
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
+ environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
@@ -1575,6 +1580,7 @@ checksums:
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
+ environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index 53a271ecfd..a7ab7b765f 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -1267,6 +1267,10 @@
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
+ "bulk_edit": "Massenbearbeitung",
+ "bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.",
+ "bulk_edit_options": "Optionen massenbearbeiten",
+ "bulk_edit_options_for": "Optionen massenbearbeiten für {language}",
"button_external": "Externen Link aktivieren",
"button_external_description": "Fügen Sie eine Schaltfläche hinzu, die eine externe URL in einem neuen Tab öffnet",
"button_label": "Beschriftung",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"optional": "Optional",
"options": "Optionen",
+ "options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.",
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
"untitled_block": "Unbenannter Block",
+ "update_options": "Optionen aktualisieren",
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
"upload": "Hochladen",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 5893d65434..6b837fcdca 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -1266,6 +1266,10 @@
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
+ "bulk_edit": "Bulk edit",
+ "bulk_edit_description": "Edit all options below, one per line. Empty lines will be skipped and duplicates removed.",
+ "bulk_edit_options": "Bulk edit options",
+ "bulk_edit_options_for": "Bulk edit options for {language}",
"button_external": "Enable External Link",
"button_external_description": "Add a button that opens an external URL in a new tab",
"button_label": "Button Label",
@@ -1512,6 +1516,7 @@
"option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.",
"optional": "Optional",
"options": "Options",
+ "options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
@@ -1661,6 +1666,7 @@
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Ask until they submit a response",
"untitled_block": "Untitled Block",
+ "update_options": "Update options",
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
"upload": "Upload",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 2b49aa2a5a..855514f611 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -1267,6 +1267,10 @@
"bold": "Negrita",
"brand_color": "Color de marca",
"brightness": "Brillo",
+ "bulk_edit": "Edición masiva",
+ "bulk_edit_description": "Edita todas las opciones a continuación, una por línea. Las líneas vacías se omitirán y los duplicados se eliminarán.",
+ "bulk_edit_options": "Edición masiva de opciones",
+ "bulk_edit_options_for": "Edición masiva de opciones para {language}",
"button_external": "Habilitar enlace externo",
"button_external_description": "Añadir un botón que abre una URL externa en una nueva pestaña",
"button_label": "Etiqueta del botón",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"optional": "Opcional",
"options": "Opciones",
+ "options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
"overwrite_global_waiting_time": "Establecer tiempo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?",
"until_they_submit_a_response": "Preguntar hasta que envíen una respuesta",
"untitled_block": "Bloque sin título",
+ "update_options": "Actualizar opciones",
"upgrade_notice_description": "Crea encuestas multilingües y desbloquea muchas más funciones",
"upgrade_notice_title": "Desbloquea encuestas multilingües con un plan superior",
"upload": "Subir",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 8fef157bb1..bb45302af8 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -1267,6 +1267,10 @@
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
+ "bulk_edit": "Modification en masse",
+ "bulk_edit_description": "Modifiez toutes les options ci-dessous, une par ligne. Les lignes vides seront ignorées et les doublons supprimés.",
+ "bulk_edit_options": "Modifier les options en masse",
+ "bulk_edit_options_for": "Modifier les options en masse pour {language}",
"button_external": "Activer le lien externe",
"button_external_description": "Ajouter un bouton qui ouvre une URL externe dans un nouvel onglet",
"button_label": "Label du bouton",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"optional": "Optionnel",
"options": "Options",
+ "options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique : {questionIndexes}. Veuillez d'abord les supprimer de la logique.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
"untitled_block": "Bloc sans titre",
+ "update_options": "Mettre à jour les options",
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
"upload": "Télécharger",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index 3be1eae10d..7fec709261 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -1267,6 +1267,10 @@
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
+ "bulk_edit": "一括編集",
+ "bulk_edit_description": "以下のオプションを1行ずつ編集してください。空の行はスキップされ、重複は削除されます。",
+ "bulk_edit_options": "オプションの一括編集",
+ "bulk_edit_options_for": "{language}のオプションを一括編集",
"button_external": "外部リンクを有効にする",
"button_external_description": "新しいタブで外部URLを開くボタンを追加する",
"button_label": "ボタンのラベル",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "このオプションは質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"optional": "オプション",
"options": "オプション",
+ "options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。",
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答が提出されるまで質問する",
"untitled_block": "無題のブロック",
+ "update_options": "オプションを更新",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index eeb63ca0f5..c6871d296f 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -1267,6 +1267,10 @@
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
"brightness": "Helderheid",
+ "bulk_edit": "Bulkbewerking",
+ "bulk_edit_description": "Bewerk alle opties hieronder, één per regel. Lege regels worden overgeslagen en duplicaten verwijderd.",
+ "bulk_edit_options": "Opties bulkbewerken",
+ "bulk_edit_options_for": "Opties bulkbewerken voor {language}",
"button_external": "Externe link inschakelen",
"button_external_description": "Voeg een knop toe die een externe URL in een nieuw tabblad opent",
"button_label": "Knoplabel",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Deze optie wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"optional": "Optioneel",
"options": "Opties",
+ "options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.",
"override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.",
"overwrite_global_waiting_time": "Stel aangepaste wachttijd in",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?",
"until_they_submit_a_response": "Vraag totdat ze een reactie indienen",
"untitled_block": "Naamloos blok",
+ "update_options": "Opties bijwerken",
"upgrade_notice_description": "Creëer meertalige enquêtes en ontgrendel nog veel meer functies",
"upgrade_notice_title": "Ontgrendel meertalige enquêtes met een hoger plan",
"upload": "Uploaden",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index effc6967ed..030d3b69ce 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -1267,6 +1267,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
+ "bulk_edit": "Edição em massa",
+ "bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicatas removidas.",
+ "bulk_edit_options": "Editar opções em massa",
+ "bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Habilitar link externo",
"button_external_description": "Adicionar um botão que abre uma URL externa em uma nova aba",
"button_label": "Rótulo do Botão",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
+ "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
"untitled_block": "Bloco sem título",
+ "update_options": "Atualizar opções",
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
"upload": "Enviar",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 36648ae707..fc505dff7f 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -1267,6 +1267,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
+ "bulk_edit": "Edição em massa",
+ "bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicados removidos.",
+ "bulk_edit_options": "Editar opções em massa",
+ "bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Ativar link externo",
"button_external_description": "Adicionar um botão que abre um URL externo num novo separador",
"button_label": "Rótulo do botão",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
+ "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
"untitled_block": "Bloco sem título",
+ "update_options": "Atualizar opções",
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
"upload": "Carregar",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index a2e611096e..4022425739 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -1267,6 +1267,10 @@
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
+ "bulk_edit": "Editare în bloc",
+ "bulk_edit_description": "Editați toate opțiunile de mai jos, câte una pe linie. Liniile goale vor fi omise, iar duplicatele vor fi eliminate.",
+ "bulk_edit_options": "Opțiuni de editare în bloc",
+ "bulk_edit_options_for": "Editare în bloc a opțiunilor pentru {language}",
"button_external": "Activează link extern",
"button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă",
"button_label": "Etichetă buton",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"optional": "Opțional",
"options": "Opțiuni",
+ "options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.",
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
"untitled_block": "Bloc fără titlu",
+ "update_options": "Actualizați opțiunile",
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
"upload": "Încărcați",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index 268221f15f..17ef00c38c 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -1267,6 +1267,10 @@
"bold": "Fet",
"brand_color": "Varumärkesfärg",
"brightness": "Ljusstyrka",
+ "bulk_edit": "Massredigera",
+ "bulk_edit_description": "Redigera alla alternativ nedan, ett per rad. Tomma rader kommer att hoppas över och dubbletter tas bort.",
+ "bulk_edit_options": "Massredigera alternativ",
+ "bulk_edit_options_for": "Massredigera alternativ för {language}",
"button_external": "Aktivera extern länk",
"button_external_description": "Lägg till en knapp som öppnar en extern URL i en ny flik",
"button_label": "Knappetikett",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "Detta alternativ används i logiken för fråga {questionIndex}. Vänligen ta bort det från logiken först.",
"optional": "Valfritt",
"options": "Alternativ",
+ "options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.",
"override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.",
"overwrite_global_waiting_time": "Ställ in anpassad väntetid",
"overwrite_global_waiting_time_description": "Åsidosätt projektkonfigurationen endast för denna enkät.",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "Du har osparade ändringar i din enkät. Vill du spara dem innan du lämnar?",
"until_they_submit_a_response": "Fråga tills de skickar in ett svar",
"untitled_block": "Namnlöst block",
+ "update_options": "Uppdatera alternativ",
"upgrade_notice_description": "Skapa flerspråkiga enkäter och lås upp många fler funktioner",
"upgrade_notice_title": "Lås upp flerspråkiga enkäter med en högre plan",
"upload": "Ladda upp",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index eaa8632fcf..4ebd8a82d0 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -1267,6 +1267,10 @@
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
+ "bulk_edit": "批量编辑",
+ "bulk_edit_description": "编辑以下所有选项,每行一个。空行将被跳过,重复项将被移除。",
+ "bulk_edit_options": "批量编辑选项",
+ "bulk_edit_options_for": "为 {language} 批量编辑选项",
"button_external": "启用外部链接",
"button_external_description": "添加一个按钮,在新标签页中打开外部URL",
"button_label": "按钮标签",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "\"这个 选项 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"optional": "可选",
"options": "选项",
+ "options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "持续显示直到提交回应",
"untitled_block": "未命名区块",
+ "update_options": "更新选项",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index ebdbbf045c..f0df904e6d 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -1267,6 +1267,10 @@
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
+ "bulk_edit": "批次編輯",
+ "bulk_edit_description": "在下方逐行編輯所有選項。空白行將被略過,重複項目將被移除。",
+ "bulk_edit_options": "批次編輯選項",
+ "bulk_edit_options_for": "為 {language} 批次編輯選項",
"button_external": "啟用外部連結",
"button_external_description": "新增一個按鈕,在新分頁中開啟外部網址",
"button_label": "按鈕標籤",
@@ -1513,6 +1517,7 @@
"option_used_in_logic_error": "此選項用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"optional": "選填",
"options": "選項",
+ "options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
@@ -1662,6 +1667,7 @@
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "持續詢問直到提交回應",
"untitled_block": "未命名區塊",
+ "update_options": "更新選項",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",
diff --git a/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx b/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx
new file mode 100644
index 0000000000..1625167910
--- /dev/null
+++ b/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { createId } from "@paralleldrive/cuid2";
+import { type JSX, useEffect, useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
+import { TI18nString } from "@formbricks/types/i18n";
+import { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
+import { TSurvey } from "@formbricks/types/surveys/types";
+import { TUserLocale } from "@formbricks/types/user";
+import { createI18nString } from "@/lib/i18n/utils";
+import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
+import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
+import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
+import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
+
+interface BulkEditOptionsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ regularChoices: TSurveyMultipleChoiceElement["choices"];
+ onSave: (updatedChoices: TSurveyMultipleChoiceElement["choices"]) => void;
+ element: TSurveyMultipleChoiceElement;
+ localSurvey: TSurvey;
+ selectedLanguageCode: string;
+ surveyLanguageCodes: string[];
+ locale: TUserLocale;
+}
+
+const parseUniqueLines = (content: string): string[] => {
+ return [
+ ...new Set(
+ content
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ ),
+ ];
+};
+
+const updateChoiceLabel = (
+ choice: TSurveyMultipleChoiceElement["choices"][number],
+ newLabel: string,
+ selectedLangCode: string,
+ allLangCodes: string[]
+): TSurveyMultipleChoiceElement["choices"][number] => {
+ const label = Object.fromEntries([
+ ...allLangCodes.map((code) => [code, choice.label[code] ?? ""]),
+ [selectedLangCode, newLabel],
+ ]) as TI18nString;
+
+ return { ...choice, label };
+};
+
+export const BulkEditOptionsModal = ({
+ isOpen,
+ onClose,
+ regularChoices,
+ onSave,
+ element,
+ localSurvey,
+ selectedLanguageCode,
+ surveyLanguageCodes,
+ locale,
+}: BulkEditOptionsModalProps): JSX.Element => {
+ const { t } = useTranslation();
+ const [textareaValue, setTextareaValue] = useState("");
+ const [validationError, setValidationError] = useState(null);
+
+ const selectedLanguageName = useMemo(() => {
+ if (localSurvey.languages.length <= 1) return null;
+ const code =
+ selectedLanguageCode === "default"
+ ? localSurvey.languages.find((lang) => lang.default)?.language.code
+ : selectedLanguageCode;
+ return code ? getLanguageLabel(code, locale) : null;
+ }, [localSurvey.languages, selectedLanguageCode, locale]);
+
+ useEffect(() => {
+ if (isOpen) {
+ setTextareaValue(regularChoices.map((c) => c.label[selectedLanguageCode] || "").join("\n"));
+ setValidationError(null);
+ }
+ }, [isOpen, regularChoices, selectedLanguageCode]);
+
+ const validateRemovedOptions = (newLabels: string[]): string | null => {
+ const originalLabels = regularChoices.map((c) => c.label[selectedLanguageCode] || "");
+ const missingLabels = originalLabels.filter((label) => label && !newLabels.includes(label));
+
+ if (missingLabels.length === 0) return null;
+
+ // Find which choices have missing labels and check if they're used in logic
+ const choicesWithMissingLabels = missingLabels
+ .map((label) => regularChoices.find((c) => c.label[selectedLanguageCode] === label))
+ .filter((c): c is TSurveyMultipleChoiceElement["choices"][number] => c !== undefined);
+
+ // Get all elements to find which block has the logic
+ const allElements = getElementsFromBlocks(localSurvey.blocks);
+
+ // Build detailed error info: option label -> block name where it's used
+ const problematicOptions: { optionLabel: string; blockName: string }[] = [];
+
+ for (const choice of choicesWithMissingLabels) {
+ const elementIndex = findOptionUsedInLogic(localSurvey, element.id, choice.id);
+ if (elementIndex !== -1) {
+ const elementWithLogic = allElements[elementIndex];
+ // Find which block contains this element
+ const { block } = findElementLocation(localSurvey, elementWithLogic.id);
+ if (block) {
+ const optionLabel = choice.label[selectedLanguageCode] || "";
+ problematicOptions.push({ optionLabel, blockName: block.name });
+ }
+ }
+ }
+
+ if (problematicOptions.length === 0) return null;
+
+ // Format: "Option '3' is used in logic at 'Block Name'"
+ const details = problematicOptions.map((opt) => `"${opt.optionLabel}" → ${opt.blockName}`).join(", ");
+
+ return t("environments.surveys.edit.options_used_in_logic_bulk_error", {
+ questionIndexes: details,
+ });
+ };
+
+ const handleSave = () => {
+ const newLabels = parseUniqueLines(textareaValue);
+ const error = validateRemovedOptions(newLabels);
+
+ if (error) {
+ setValidationError(error);
+ return;
+ }
+
+ const updatedChoices = newLabels.map((label, idx) =>
+ idx < regularChoices.length
+ ? updateChoiceLabel(regularChoices[idx], label, selectedLanguageCode, surveyLanguageCodes)
+ : { id: createId(), label: createI18nString(label, surveyLanguageCodes) }
+ );
+
+ onSave(updatedChoices);
+ onClose();
+ toast.success(t("environments.surveys.edit.changes_saved"));
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx b/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx
index b11fe0d839..671ca0e3b3 100644
--- a/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx
+++ b/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx
@@ -8,12 +8,14 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
+import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
+import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -49,6 +51,7 @@ export const MultipleChoiceElementForm = ({
const lastChoiceRef = useRef(null);
const [isNew, setIsNew] = useState(true);
const [isInvalidValue, setisInvalidValue] = useState(null);
+ const [isBulkEditOpen, setIsBulkEditOpen] = useState(false);
const elementRef = useRef(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -90,11 +93,31 @@ export const MultipleChoiceElementForm = ({
[element.choices]
);
+ // Get the display name for the selected language (for multi-language surveys)
+ const bulkEditButtonLabel = useMemo(() => {
+ if (localSurvey.languages.length <= 1) {
+ return t("environments.surveys.edit.bulk_edit");
+ }
+
+ const languageCode =
+ selectedLanguageCode === "default"
+ ? localSurvey.languages.find((lang) => lang.default)?.language.code
+ : selectedLanguageCode;
+
+ const languageName = languageCode ? getLanguageLabel(languageCode, locale) : "";
+ return `${t("environments.surveys.edit.bulk_edit")} (${languageName})`;
+ }, [localSurvey.languages, selectedLanguageCode, locale, t]);
+
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceElement["choices"]) => {
+ const regularChoicesFromInput = choices.filter((c) => c.id !== "other" && c.id !== "none");
const otherChoice = choices.find((c) => c.id === "other");
const noneChoice = choices.find((c) => c.id === "none");
// [regularChoices, otherChoice, noneChoice]
- return [...regularChoices, ...(otherChoice ? [otherChoice] : []), ...(noneChoice ? [noneChoice] : [])];
+ return [
+ ...regularChoicesFromInput,
+ ...(otherChoice ? [otherChoice] : []),
+ ...(noneChoice ? [noneChoice] : []),
+ ];
};
const addChoice = (choiceIdx?: number) => {
@@ -283,7 +306,7 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, { choices: newChoices });
}}>
-
+
{element.choices?.map((choice, choiceIdx) => (
+
+
+
{specialChoices.map((specialChoice) => {
@@ -323,6 +349,9 @@ export const MultipleChoiceElementForm = ({
);
})}
+ setIsBulkEditOpen(true)}>
+ {bulkEditButtonLabel}
+
+
setIsBulkEditOpen(false)}
+ regularChoices={regularChoices}
+ onSave={(updatedChoices) => {
+ const newChoices = ensureSpecialChoicesOrder([
+ ...updatedChoices,
+ ...element.choices.filter((c) => c.id === "other" || c.id === "none"),
+ ]);
+ updateElement(elementIdx, { choices: newChoices });
+ }}
+ element={element}
+ localSurvey={localSurvey}
+ selectedLanguageCode={selectedLanguageCode}
+ surveyLanguageCodes={surveyLanguageCodes}
+ locale={locale}
+ />
);
};
From c189af548267d244a2a4619bb1a2f8ff0fdbd045 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 12 Dec 2025 11:25:57 +0100
Subject: [PATCH 06/24] chore(deps): bump the npm_and_yarn group across 2
directories with 1 update (#6971)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt
---
apps/web/package.json | 2 +-
pnpm-lock.yaml | 279 +++++++++++++++++++++++-------------------
2 files changed, 157 insertions(+), 124 deletions(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index 7a306ec300..eb31d6dc5c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -102,7 +102,7 @@
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
- "next": "15.5.7",
+ "next": "15.5.9",
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc83c3f7ca..d177787e72 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -269,7 +269,7 @@ importers:
version: 0.0.38(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@sentry/nextjs':
specifier: 10.5.0
- version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))
+ version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.25)(typescript@5.8.3)(zod@3.24.4)
@@ -358,14 +358,14 @@ importers:
specifier: 3.0.1
version: 3.0.1
next:
- specifier: 15.5.7
- version: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ specifier: 15.5.9
+ version: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next-auth:
specifier: 4.24.12
- version: 4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ version: 4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next-safe-action:
specifier: 7.10.8
- version: 7.10.8(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4)
+ version: 7.10.8(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -1608,6 +1608,9 @@ packages:
'@emnapi/runtime@1.6.0':
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
+ '@emnapi/runtime@1.7.1':
+ resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
+
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -2021,8 +2024,8 @@ packages:
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-arm64@0.34.4':
- resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==}
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
@@ -2033,8 +2036,8 @@ packages:
cpu: [x64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.4':
- resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==}
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
@@ -2044,8 +2047,8 @@ packages:
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.3':
- resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==}
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
@@ -2054,8 +2057,8 @@ packages:
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.3':
- resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==}
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
@@ -2064,8 +2067,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@img/sharp-libvips-linux-arm64@1.2.3':
- resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
@@ -2074,8 +2077,8 @@ packages:
cpu: [arm]
os: [linux]
- '@img/sharp-libvips-linux-arm@1.2.3':
- resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
@@ -2084,18 +2087,23 @@ packages:
cpu: [ppc64]
os: [linux]
- '@img/sharp-libvips-linux-ppc64@1.2.3':
- resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
'@img/sharp-libvips-linux-s390x@1.1.0':
resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
cpu: [s390x]
os: [linux]
- '@img/sharp-libvips-linux-s390x@1.2.3':
- resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
@@ -2104,8 +2112,8 @@ packages:
cpu: [x64]
os: [linux]
- '@img/sharp-libvips-linux-x64@1.2.3':
- resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
@@ -2114,8 +2122,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.3':
- resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
@@ -2124,8 +2132,8 @@ packages:
cpu: [x64]
os: [linux]
- '@img/sharp-libvips-linuxmusl-x64@1.2.3':
- resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
@@ -2135,8 +2143,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@img/sharp-linux-arm64@0.34.4':
- resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
@@ -2147,26 +2155,32 @@ packages:
cpu: [arm]
os: [linux]
- '@img/sharp-linux-arm@0.34.4':
- resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
- '@img/sharp-linux-ppc64@0.34.4':
- resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
'@img/sharp-linux-s390x@0.34.1':
resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
- '@img/sharp-linux-s390x@0.34.4':
- resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
@@ -2177,8 +2191,8 @@ packages:
cpu: [x64]
os: [linux]
- '@img/sharp-linux-x64@0.34.4':
- resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
@@ -2189,8 +2203,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@img/sharp-linuxmusl-arm64@0.34.4':
- resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
@@ -2201,8 +2215,8 @@ packages:
cpu: [x64]
os: [linux]
- '@img/sharp-linuxmusl-x64@0.34.4':
- resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
@@ -2212,13 +2226,13 @@ packages:
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
- '@img/sharp-wasm32@0.34.4':
- resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.4':
- resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==}
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
@@ -2229,8 +2243,8 @@ packages:
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-ia32@0.34.4':
- resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==}
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
@@ -2241,8 +2255,8 @@ packages:
cpu: [x64]
os: [win32]
- '@img/sharp-win32-x64@0.34.4':
- resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==}
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
@@ -2432,8 +2446,8 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
- '@next/env@15.5.7':
- resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==}
+ '@next/env@15.5.9':
+ resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
'@next/eslint-plugin-next@15.3.2':
resolution: {integrity: sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==}
@@ -5481,6 +5495,9 @@ packages:
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
+ caniuse-lite@1.0.30001760:
+ resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -7751,10 +7768,9 @@ packages:
zod:
optional: true
- next@15.5.7:
- resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==}
+ next@15.5.9:
+ resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
- deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -8885,8 +8901,8 @@ packages:
resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
- sharp@0.34.4:
- resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
@@ -11831,6 +11847,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.7.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
@@ -12114,9 +12135,9 @@ snapshots:
'@img/sharp-libvips-darwin-arm64': 1.1.0
optional: true
- '@img/sharp-darwin-arm64@0.34.4':
+ '@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.3
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.1':
@@ -12124,63 +12145,66 @@ snapshots:
'@img/sharp-libvips-darwin-x64': 1.1.0
optional: true
- '@img/sharp-darwin-x64@0.34.4':
+ '@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.3
+ '@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.1.0':
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.3':
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.1.0':
optional: true
- '@img/sharp-libvips-darwin-x64@1.2.3':
+ '@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.1.0':
optional: true
- '@img/sharp-libvips-linux-arm64@1.2.3':
+ '@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.1.0':
optional: true
- '@img/sharp-libvips-linux-arm@1.2.3':
+ '@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.1.0':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.2.3':
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.1.0':
optional: true
- '@img/sharp-libvips-linux-s390x@1.2.3':
+ '@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.1.0':
optional: true
- '@img/sharp-libvips-linux-x64@1.2.3':
+ '@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.1.0':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.2.3':
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.1.0':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.2.3':
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.1':
@@ -12188,9 +12212,9 @@ snapshots:
'@img/sharp-libvips-linux-arm64': 1.1.0
optional: true
- '@img/sharp-linux-arm64@0.34.4':
+ '@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.3
+ '@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.1':
@@ -12198,14 +12222,19 @@ snapshots:
'@img/sharp-libvips-linux-arm': 1.1.0
optional: true
- '@img/sharp-linux-arm@0.34.4':
+ '@img/sharp-linux-arm@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.3
+ '@img/sharp-libvips-linux-arm': 1.2.4
optional: true
- '@img/sharp-linux-ppc64@0.34.4':
+ '@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.3
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.1':
@@ -12213,9 +12242,9 @@ snapshots:
'@img/sharp-libvips-linux-s390x': 1.1.0
optional: true
- '@img/sharp-linux-s390x@0.34.4':
+ '@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.3
+ '@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.1':
@@ -12223,9 +12252,9 @@ snapshots:
'@img/sharp-libvips-linux-x64': 1.1.0
optional: true
- '@img/sharp-linux-x64@0.34.4':
+ '@img/sharp-linux-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.3
+ '@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.1':
@@ -12233,9 +12262,9 @@ snapshots:
'@img/sharp-libvips-linuxmusl-arm64': 1.1.0
optional: true
- '@img/sharp-linuxmusl-arm64@0.34.4':
+ '@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.3
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.1':
@@ -12243,9 +12272,9 @@ snapshots:
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
optional: true
- '@img/sharp-linuxmusl-x64@0.34.4':
+ '@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.3
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.1':
@@ -12253,24 +12282,24 @@ snapshots:
'@emnapi/runtime': 1.6.0
optional: true
- '@img/sharp-wasm32@0.34.4':
+ '@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.6.0
+ '@emnapi/runtime': 1.7.1
optional: true
- '@img/sharp-win32-arm64@0.34.4':
+ '@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.1':
optional: true
- '@img/sharp-win32-ia32@0.34.4':
+ '@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.1':
optional: true
- '@img/sharp-win32-x64@0.34.4':
+ '@img/sharp-win32-x64@0.34.5':
optional: true
'@intercom/messenger-js-sdk@0.0.14': {}
@@ -12596,13 +12625,13 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.6.0
- '@emnapi/runtime': 1.6.0
+ '@emnapi/runtime': 1.7.1
'@tybys/wasm-util': 0.10.1
optional: true
'@neoconfetti/react@1.0.0': {}
- '@next/env@15.5.7': {}
+ '@next/env@15.5.9': {}
'@next/eslint-plugin-next@15.3.2':
dependencies:
@@ -14129,7 +14158,7 @@ snapshots:
'@sentry/core@10.5.0': {}
- '@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))':
+ '@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.37.0
@@ -14142,7 +14171,7 @@ snapshots:
'@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.6.0(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.10))
chalk: 3.0.0
- next: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
resolve: 1.22.8
rollup: 4.52.5
stacktrace-parser: 0.1.11
@@ -16073,6 +16102,8 @@ snapshots:
caniuse-lite@1.0.30001751: {}
+ caniuse-lite@1.0.30001760: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -18570,13 +18601,13 @@ snapshots:
neo-async@2.6.2: {}
- next-auth@4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
+ next-auth@4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
- next: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.26.6
@@ -18587,19 +18618,19 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.11
- next-safe-action@7.10.8(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4):
+ next-safe-action@7.10.8(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4):
dependencies:
- next: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
optionalDependencies:
zod: 3.24.4
- next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
+ next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
dependencies:
- '@next/env': 15.5.7
+ '@next/env': 15.5.9
'@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001751
+ caniuse-lite: 1.0.30001760
postcss: 8.4.31
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
@@ -18615,7 +18646,7 @@ snapshots:
'@next/swc-win32-x64-msvc': 15.5.7
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.56.1
- sharp: 0.34.4
+ sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -19810,34 +19841,36 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.1
'@img/sharp-win32-x64': 0.34.1
- sharp@0.34.4:
+ sharp@0.34.5:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.3
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.4
- '@img/sharp-darwin-x64': 0.34.4
- '@img/sharp-libvips-darwin-arm64': 1.2.3
- '@img/sharp-libvips-darwin-x64': 1.2.3
- '@img/sharp-libvips-linux-arm': 1.2.3
- '@img/sharp-libvips-linux-arm64': 1.2.3
- '@img/sharp-libvips-linux-ppc64': 1.2.3
- '@img/sharp-libvips-linux-s390x': 1.2.3
- '@img/sharp-libvips-linux-x64': 1.2.3
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.3
- '@img/sharp-libvips-linuxmusl-x64': 1.2.3
- '@img/sharp-linux-arm': 0.34.4
- '@img/sharp-linux-arm64': 0.34.4
- '@img/sharp-linux-ppc64': 0.34.4
- '@img/sharp-linux-s390x': 0.34.4
- '@img/sharp-linux-x64': 0.34.4
- '@img/sharp-linuxmusl-arm64': 0.34.4
- '@img/sharp-linuxmusl-x64': 0.34.4
- '@img/sharp-wasm32': 0.34.4
- '@img/sharp-win32-arm64': 0.34.4
- '@img/sharp-win32-ia32': 0.34.4
- '@img/sharp-win32-x64': 0.34.4
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
From 56da3b5725f40f4bad0020f052d423fa3e6a9b1f Mon Sep 17 00:00:00 2001
From: Bhagya Amarasinghe
Date: Fri, 12 Dec 2025 15:59:26 +0530
Subject: [PATCH 07/24] chore: remove docker compose version pinning and update
Traefik image version to v2.11.31 in docker-compose and documentation (#6967)
Co-authored-by: Matti Nannt
---
docker/docker-compose.yml | 1 -
docker/formbricks.sh | 4 ++--
docs/self-hosting/configuration/custom-ssl.mdx | 2 +-
3 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 7056fbc164..436ea5190d 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,4 +1,3 @@
-version: "3.3"
x-environment: &environment
environment:
######################################################## REQUIRED ########################################################
diff --git a/docker/formbricks.sh b/docker/formbricks.sh
index 909a086091..8347d11ca6 100755
--- a/docker/formbricks.sh
+++ b/docker/formbricks.sh
@@ -496,7 +496,7 @@ EOF
if [[ $insert_traefik == "y" ]]; then
cat >> "$services_snippet_file" << EOF
traefik:
- image: "traefik:v2.11.29"
+ image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:
@@ -525,7 +525,7 @@ EOF
cat > "$services_snippet_file" << EOF
traefik:
- image: "traefik:v2.11.29"
+ image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:
diff --git a/docs/self-hosting/configuration/custom-ssl.mdx b/docs/self-hosting/configuration/custom-ssl.mdx
index 257d898fe3..952097b28e 100644
--- a/docs/self-hosting/configuration/custom-ssl.mdx
+++ b/docs/self-hosting/configuration/custom-ssl.mdx
@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
<<: *environment
traefik:
- image: "traefik:v2.7"
+ image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:
From ffb4eac1a4da002e2a27e707aed179582dc1628a Mon Sep 17 00:00:00 2001
From: Matti Nannt
Date: Fri, 12 Dec 2025 19:14:21 +0100
Subject: [PATCH 08/24] chore: upgrade azure-playwright (#6949)
---
.github/workflows/e2e.yml | 49 +++++++++------------
package.json | 3 +-
playwright.service.config.ts | 28 ++++--------
pnpm-lock.yaml | 82 ++++++------------------------------
4 files changed, 45 insertions(+), 117 deletions(-)
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index ada2c86f84..bf12561d94 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -3,14 +3,10 @@ name: E2E Tests
on:
workflow_call:
secrets:
- AZURE_CLIENT_ID:
- required: false
- AZURE_TENANT_ID:
- required: false
- AZURE_SUBSCRIPTION_ID:
- required: false
PLAYWRIGHT_SERVICE_URL:
required: false
+ PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
+ required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
@@ -21,7 +17,6 @@ env:
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
- id-token: write
contents: read
actions: read
@@ -207,32 +202,30 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- - name: Set Azure Secret Variables
- run: |
- if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
- echo "AZURE_ENABLED=true" >> $GITHUB_ENV
- else
- echo "AZURE_ENABLED=false" >> $GITHUB_ENV
- fi
-
- - name: Azure login
- if: env.AZURE_ENABLED == 'true'
- uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
- with:
- client-id: ${{ secrets.AZURE_CLIENT_ID }}
- tenant-id: ${{ secrets.AZURE_TENANT_ID }}
- subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
-
- - name: Run E2E Tests (Azure)
- if: env.AZURE_ENABLED == 'true'
+ - name: Determine Playwright execution mode
+ shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
- CI: true
+ PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: |
- pnpm test-e2e:azure
+ set -euo pipefail
+
+ if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
+ echo "PW_MODE=service" >> "$GITHUB_ENV"
+ else
+ echo "PW_MODE=local" >> "$GITHUB_ENV"
+ fi
+
+ - name: Run E2E Tests (Playwright Service)
+ if: env.PW_MODE == 'service'
+ env:
+ PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
+ PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
+ CI: true
+ run: pnpm test-e2e:azure
- name: Run E2E Tests (Local)
- if: env.AZURE_ENABLED == 'false'
+ if: env.PW_MODE == 'local'
env:
CI: true
run: |
diff --git a/package.json b/package.json
index 5ce153bb31..3f46fcb724 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,8 @@
"react-dom": "19.1.2"
},
"devDependencies": {
- "@azure/microsoft-playwright-testing": "1.0.0-beta.7",
+ "@azure/identity": "4.13.0",
+ "@azure/playwright": "1.0.0",
"@formbricks/eslint-config": "workspace:*",
"@playwright/test": "1.56.1",
"eslint": "8.57.0",
diff --git a/playwright.service.config.ts b/playwright.service.config.ts
index c805bee01a..0c2e944af5 100644
--- a/playwright.service.config.ts
+++ b/playwright.service.config.ts
@@ -1,24 +1,14 @@
-import { ServiceOS, getServiceConfig } from "@azure/microsoft-playwright-testing";
-import { defineConfig } from "@playwright/test";
-import config from "./playwright.config";
+import { createAzurePlaywrightConfig, ServiceAuth, ServiceOS } from '@azure/playwright';
+import { defineConfig } from '@playwright/test';
+import config from './playwright.config';
-/* Learn more about service configuration at https://aka.ms/mpt/config */
+/* Learn more about service configuration at https://aka.ms/pww/docs/config */
export default defineConfig(
config,
- getServiceConfig(config, {
- exposeNetwork: "",
- timeout: 120000, // Increased timeout for cloud environment with network latency
+ createAzurePlaywrightConfig(config, {
+ exposeNetwork: '',
+ connectTimeout: 3 * 60 * 1000, // 3 minutes
os: ServiceOS.LINUX,
- useCloudHostedBrowsers: true, // Set to false if you want to only use reporting and not cloud hosted browsers
- }),
- {
- /*
- Playwright Testing service reporter is added by default.
- This will override any reporter options specified in the base playwright config.
- If you are using more reporters, please update your configuration accordingly.
- */
- reporter: [["list"], ["@azure/microsoft-playwright-testing/reporter"]],
- retries: 2, // Always retry in cloud environment due to potential network/timing issues
- maxFailures: undefined, // Don't stop on first failure to avoid cascading shutdowns with high parallelism
- }
+ serviceAuthType: ServiceAuth.ACCESS_TOKEN
+ })
);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d177787e72..072e458600 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,9 +26,12 @@ importers:
specifier: 19.1.2
version: 19.1.2(react@19.1.2)
devDependencies:
- '@azure/microsoft-playwright-testing':
- specifier: 1.0.0-beta.7
- version: 1.0.0-beta.7(@playwright/test@1.56.1)
+ '@azure/identity':
+ specifier: 4.13.0
+ version: 4.13.0
+ '@azure/playwright':
+ specifier: 1.0.0
+ version: 1.0.0(@playwright/test@1.56.1)
'@formbricks/eslint-config':
specifier: workspace:*
version: link:packages/config-eslint
@@ -1347,10 +1350,6 @@ packages:
resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
engines: {node: '>=20.0.0'}
- '@azure/core-xml@1.5.0':
- resolution: {integrity: sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==}
- engines: {node: '>=20.0.0'}
-
'@azure/identity@4.13.0':
resolution: {integrity: sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==}
engines: {node: '>=20.0.0'}
@@ -1367,13 +1366,6 @@ packages:
resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==}
engines: {node: '>=20.0.0'}
- '@azure/microsoft-playwright-testing@1.0.0-beta.7':
- resolution: {integrity: sha512-Y6C35LWUfLevHu5NG+7vvFfhpmUrGWKRumcz7/CSCmWlx8RVfWgP6NuL8rIPDuTeJyjaTczNfeg1ppGW26TjBw==}
- engines: {node: '>=18.0.0'}
- deprecated: This package has been deprecated and will no longer be maintained after March 8, 2026. Upgrade to the replacement package, @azure/playwright, to continue receiving updates.
- peerDependencies:
- '@playwright/test': ^1.43.1
-
'@azure/msal-browser@4.26.0':
resolution: {integrity: sha512-Ie3SZ4IMrf9lSwWVzzJrhTPE+g9+QDUfeor1LKMBQzcblp+3J/U1G8hMpNSfLL7eA5F/DjjPXkATJ5JRUdDJLA==}
engines: {node: '>=0.8.0'}
@@ -1386,13 +1378,11 @@ packages:
resolution: {integrity: sha512-HszfqoC+i2C9+BRDQfuNUGp15Re7menIhCEbFCQ49D3KaqEDrgZIgQ8zSct4T59jWeUIL9N/Dwiv4o2VueTdqQ==}
engines: {node: '>=16'}
- '@azure/storage-blob@12.29.1':
- resolution: {integrity: sha512-7ktyY0rfTM0vo7HvtK6E3UvYnI9qfd6Oz6z/+92VhGRveWng3kJwMKeUpqmW/NmwcDNbxHpSlldG+vsUnRFnBg==}
- engines: {node: '>=20.0.0'}
-
- '@azure/storage-common@12.1.1':
- resolution: {integrity: sha512-eIOH1pqFwI6UmVNnDQvmFeSg0XppuzDLFeUNO/Xht7ODAzRLgGDh7h550pSxoA+lPDxBl1+D2m/KG3jWzCUjTg==}
+ '@azure/playwright@1.0.0':
+ resolution: {integrity: sha512-HG0XuYT0z7TcyffJZrEZ9fWrP/WsK/jDAmKFB7MuYNy9WMW0oQItUy+o6uIv2IiZzUeO8OpyhompOhpFddf+eQ==}
engines: {node: '>=20.0.0'}
+ peerDependencies:
+ '@playwright/test': ^1.47.0
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
@@ -6445,10 +6435,6 @@ packages:
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
hasBin: true
- fast-xml-parser@5.3.0:
- resolution: {integrity: sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==}
- hasBin: true
-
fastest-stable-stringify@2.0.2:
resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==}
@@ -7771,6 +7757,7 @@ packages:
next@15.5.9:
resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
+ deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -11423,11 +11410,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@azure/core-xml@1.5.0':
- dependencies:
- fast-xml-parser: 5.3.0
- tslib: 2.8.1
-
'@azure/identity@4.13.0':
dependencies:
'@azure/abort-controller': 2.1.2
@@ -11481,17 +11463,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@azure/microsoft-playwright-testing@1.0.0-beta.7(@playwright/test@1.56.1)':
- dependencies:
- '@azure/core-rest-pipeline': 1.22.1
- '@azure/identity': 4.13.0
- '@azure/logger': 1.3.0
- '@azure/storage-blob': 12.29.1
- '@playwright/test': 1.56.1
- tslib: 2.8.1
- transitivePeerDependencies:
- - supports-color
-
'@azure/msal-browser@4.26.0':
dependencies:
'@azure/msal-common': 15.13.1
@@ -11504,35 +11475,12 @@ snapshots:
jsonwebtoken: 9.0.2
uuid: 8.3.2
- '@azure/storage-blob@12.29.1':
+ '@azure/playwright@1.0.0(@playwright/test@1.56.1)':
dependencies:
- '@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
- '@azure/core-client': 1.10.1
- '@azure/core-http-compat': 2.3.1
- '@azure/core-lro': 2.7.2
- '@azure/core-paging': 1.6.2
'@azure/core-rest-pipeline': 1.22.1
- '@azure/core-tracing': 1.3.1
- '@azure/core-util': 1.13.1
- '@azure/core-xml': 1.5.0
'@azure/logger': 1.3.0
- '@azure/storage-common': 12.1.1
- events: 3.3.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - supports-color
-
- '@azure/storage-common@12.1.1':
- dependencies:
- '@azure/abort-controller': 2.1.2
- '@azure/core-auth': 1.10.1
- '@azure/core-http-compat': 2.3.1
- '@azure/core-rest-pipeline': 1.22.1
- '@azure/core-tracing': 1.3.1
- '@azure/core-util': 1.13.1
- '@azure/logger': 1.3.0
- events: 3.3.0
+ '@playwright/test': 1.56.1
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -17237,10 +17185,6 @@ snapshots:
dependencies:
strnum: 2.1.1
- fast-xml-parser@5.3.0:
- dependencies:
- strnum: 2.1.1
-
fastest-stable-stringify@2.0.2: {}
fastq@1.19.1:
From 6bc7db852c7946f880c4244bf95d7d06942958d4 Mon Sep 17 00:00:00 2001
From: Johannes <72809645+jobenjada@users.noreply.github.com>
Date: Fri, 12 Dec 2025 13:52:00 -0800
Subject: [PATCH 09/24] feat: Save draft without validation (Duplicate of
#6847) (#6966)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
Co-authored-by: pandeymangg
---
apps/web/i18n.lock | 1 +
apps/web/lib/survey/service.test.ts | 72 +++++++++++++++
apps/web/lib/survey/service.ts | 26 +++++-
apps/web/locales/de-DE.json | 1 +
apps/web/locales/en-US.json | 1 +
apps/web/locales/es-ES.json | 1 +
apps/web/locales/fr-FR.json | 1 +
apps/web/locales/ja-JP.json | 1 +
apps/web/locales/nl-NL.json | 1 +
apps/web/locales/pt-BR.json | 1 +
apps/web/locales/pt-PT.json | 1 +
apps/web/locales/ro-RO.json | 1 +
apps/web/locales/sv-SE.json | 1 +
apps/web/locales/zh-Hans-CN.json | 1 +
apps/web/locales/zh-Hant-TW.json | 1 +
apps/web/modules/survey/editor/actions.ts | 59 +++++++++++-
.../editor/components/survey-menu-bar.tsx | 40 +++++++-
.../modules/survey/editor/lib/survey.test.ts | 92 ++++++++++++++++++-
apps/web/modules/survey/editor/lib/survey.ts | 6 ++
.../web/modules/survey/editor/types/survey.ts | 27 ++++++
20 files changed, 324 insertions(+), 11 deletions(-)
create mode 100644 apps/web/modules/survey/editor/types/survey.ts
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index f3462e2ee5..247d85eb9b 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -328,6 +328,7 @@ checksums:
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
+ common/save_as_draft: b1b38812110113627d141db981fb1b12
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/saving: 27ad05746d65e2f3f17d327eb181725d
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts
index 6608dfd2a5..09955d4ba0 100644
--- a/apps/web/lib/survey/service.test.ts
+++ b/apps/web/lib/survey/service.test.ts
@@ -33,6 +33,7 @@ import {
handleTriggerUpdates,
loadNewSegmentInSurvey,
updateSurvey,
+ updateSurveyInternal,
} from "./service";
// Mock organization service
@@ -948,3 +949,74 @@ describe("Tests for getSurveysBySegmentId", () => {
});
});
});
+
+describe("updateSurveyDraftAction", () => {
+ beforeEach(() => {
+ vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as TActionClass[]);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganizationOutput);
+ });
+
+ describe("Happy Path", () => {
+ test("should save draft with missing translations", async () => {
+ prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
+ prisma.survey.update.mockResolvedValue(mockSurveyOutput);
+
+ // Create a survey with incomplete i18n/fields
+ const incompleteSurvey = {
+ ...updateSurveyInput,
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ // Missing headline or other required fields
+ },
+ ],
+ } as unknown as TSurvey;
+
+ // Expect success (skipValidation = true)
+ const result = await updateSurveyInternal(incompleteSurvey, true);
+ expect(result).toBeDefined();
+ expect(prisma.survey.update).toHaveBeenCalled();
+ });
+
+ test("should allow draft with invalid images if gating is applied", async () => {
+ prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
+ prisma.survey.update.mockResolvedValue(mockSurveyOutput);
+
+ const surveyWithInvalidImage = {
+ ...updateSurveyInput,
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question" },
+ imageUrl: "http://invalid-image-url.com/image.txt", // Invalid image extension
+ },
+ ],
+ } as unknown as TSurvey;
+
+ // Expect success (skipValidation = true)
+ await updateSurveyInternal(surveyWithInvalidImage, true);
+ expect(prisma.survey.update).toHaveBeenCalled();
+ });
+ });
+
+ describe("Sad Path", () => {
+ test("should reject publishing survey with incomplete translations", async () => {
+ // Create a draft with missing translations
+ const incompleteSurvey = {
+ ...updateSurveyInput,
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ // Missing headline
+ },
+ ],
+ } as unknown as TSurvey;
+
+ // Expect validation error (skipValidation = false)
+ await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
+ });
+ });
+});
diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts
index 6084f6e0e0..52388e88be 100644
--- a/apps/web/lib/survey/service.ts
+++ b/apps/web/lib/survey/service.ts
@@ -284,8 +284,13 @@ export const getSurveyCount = reactCache(async (environmentId: string): Promise<
}
});
-export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
- validateInputs([updatedSurvey, ZSurvey]);
+export const updateSurveyInternal = async (
+ updatedSurvey: TSurvey,
+ skipValidation = false
+): Promise => {
+ if (!skipValidation) {
+ validateInputs([updatedSurvey, ZSurvey]);
+ }
try {
const surveyId = updatedSurvey.id;
@@ -301,10 +306,12 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
- checkForInvalidImagesInQuestions(questions);
+ if (!skipValidation) {
+ checkForInvalidImagesInQuestions(questions);
+ }
// Add blocks media validation
- if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
+ if (!skipValidation && updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
if (!blocksValidation.ok) {
throw new InvalidInputError(blocksValidation.error.message);
@@ -368,7 +375,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
- if (!parsedFilters.success) {
+ if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
@@ -568,6 +575,15 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
}
};
+export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
+ return updateSurveyInternal(updatedSurvey);
+};
+
+// Draft update without validation
+export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise => {
+ return updateSurveyInternal(updatedSurvey, true);
+};
+
export const createSurvey = async (
environmentId: string,
surveyBody: TSurveyCreateInput
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index a7ab7b765f..c35023bbbe 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
+ "save_as_draft": "Als Entwurf speichern",
"save_changes": "Änderungen speichern",
"saving": "Speichern",
"search": "Suchen",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 6b837fcdca..a06cc6fb20 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -354,6 +354,7 @@
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
+ "save_as_draft": "Save as draft",
"save_changes": "Save changes",
"saving": "Saving",
"search": "Search",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 855514f611..e40be6686d 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
+ "save_as_draft": "Guardar como borrador",
"save_changes": "Guardar cambios",
"saving": "Guardando",
"search": "Buscar",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index bb45302af8..2ed10a96f4 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
+ "save_as_draft": "Enregistrer comme brouillon",
"save_changes": "Enregistrer les modifications",
"saving": "Sauvegarder",
"search": "Recherche",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index 7fec709261..0b472edf14 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
+ "save_as_draft": "下書きとして保存",
"save_changes": "変更を保存",
"saving": "保存中",
"search": "検索",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index c6871d296f..6828e01700 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
+ "save_as_draft": "Opslaan als concept",
"save_changes": "Wijzigingen opslaan",
"saving": "Besparing",
"search": "Zoekopdracht",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 030d3b69ce..7aca515c9a 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
+ "save_as_draft": "Salvar como rascunho",
"save_changes": "Salvar alterações",
"saving": "Salvando",
"search": "Buscar",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index fc505dff7f..9fcf2928f7 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
+ "save_as_draft": "Guardar como rascunho",
"save_changes": "Guardar alterações",
"saving": "Guardando",
"search": "Procurar",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 4022425739..df8843374f 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
+ "save_as_draft": "Salvați ca schiță",
"save_changes": "Salvează modificările",
"saving": "Salvare",
"search": "Căutare",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index 17ef00c38c..b259cfbe9c 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "Försäljning",
"save": "Spara",
+ "save_as_draft": "Spara som utkast",
"save_changes": "Spara ändringar",
"saving": "Sparar",
"search": "Sök",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index 4ebd8a82d0..cb896bd13c 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "销售",
"save": "保存",
+ "save_as_draft": "保存为草稿",
"save_changes": "保存 更改",
"saving": "保存",
"search": "搜索",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index f0df904e6d..c15c833a35 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -355,6 +355,7 @@
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
+ "save_as_draft": "儲存為草稿",
"save_changes": "儲存變更",
"saving": "儲存",
"search": "搜尋",
diff --git a/apps/web/modules/survey/editor/actions.ts b/apps/web/modules/survey/editor/actions.ts
index 8de3ad3bc8..e44c17235b 100644
--- a/apps/web/modules/survey/editor/actions.ts
+++ b/apps/web/modules/survey/editor/actions.ts
@@ -20,7 +20,8 @@ import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
-import { updateSurvey } from "@/modules/survey/editor/lib/survey";
+import { updateSurvey, updateSurveyDraft } from "@/modules/survey/editor/lib/survey";
+import { TSurveyDraft, ZSurveyDraft } from "@/modules/survey/editor/types/survey";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
@@ -46,6 +47,62 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise {
+ // Cast to TSurvey - ZSurveyDraft validates structure, full validation happens on publish
+ const survey = parsedInput as TSurvey;
+
+ const organizationId = await getOrganizationIdFromSurveyId(survey.id);
+ await checkAuthorizationUpdated({
+ userId: ctx.user.id,
+ organizationId,
+ access: [
+ {
+ type: "organization",
+ roles: ["owner", "manager"],
+ },
+ {
+ type: "projectTeam",
+ projectId: await getProjectIdFromSurveyId(survey.id),
+ minPermission: "readWrite",
+ },
+ ],
+ });
+
+ if (survey.recaptcha?.enabled) {
+ await checkSpamProtectionPermission(organizationId);
+ }
+
+ if (survey.followUps?.length) {
+ await checkSurveyFollowUpsPermission(organizationId);
+ }
+
+ if (survey.languages?.length) {
+ await checkMultiLanguagePermission(organizationId);
+ }
+
+ ctx.auditLoggingCtx.organizationId = organizationId;
+ ctx.auditLoggingCtx.surveyId = survey.id;
+ const oldObject = await getSurvey(survey.id);
+
+ await checkExternalUrlsPermission(organizationId, survey, oldObject);
+
+ // Use the draft version that skips validation
+ const result = await updateSurveyDraft(survey);
+
+ ctx.auditLoggingCtx.oldObject = oldObject;
+ ctx.auditLoggingCtx.newObject = result;
+
+ revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
+
+ return result;
+ }
+ )
+);
+
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
index be911ea18d..02caec3a75 100644
--- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
+++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
@@ -19,11 +19,12 @@ import {
} from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
+import { TSurveyDraft } from "@/modules/survey/editor/types/survey";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
-import { updateSurveyAction } from "../actions";
+import { updateSurveyAction, updateSurveyDraftAction } from "../actions";
import { isSurveyValid } from "../lib/validation";
interface SurveyMenuBarProps {
@@ -227,6 +228,38 @@ export const SurveyMenuBar = ({
return true;
};
+ // Add new handler after handleSurveySave
+ const handleSurveySaveDraft = async (): Promise => {
+ setIsSurveySaving(true);
+
+ try {
+ const segment = await handleSegmentUpdate();
+ clearSurveyLocalStorage();
+ const updatedSurveyResponse = await updateSurveyDraftAction({
+ ...localSurvey,
+ segment,
+ } as unknown as TSurveyDraft);
+
+ setIsSurveySaving(false);
+ if (updatedSurveyResponse?.data) {
+ setLocalSurvey(updatedSurveyResponse.data);
+ toast.success(t("environments.surveys.edit.changes_saved"));
+ isSuccessfullySavedRef.current = true;
+ router.refresh();
+ } else {
+ const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
+ toast.error(errorMessage);
+ return false;
+ }
+ return true;
+ } catch (e) {
+ console.error(e);
+ setIsSurveySaving(false);
+ toast.error(t("environments.surveys.edit.error_saving_changes"));
+ return false;
+ }
+ };
+
const handleSurveySave = async (): Promise => {
setIsSurveySaving(true);
@@ -398,12 +431,11 @@ export const SurveyMenuBar = ({
variant="secondary"
size="sm"
loading={isSurveySaving}
- onClick={() => handleSurveySave()}
+ onClick={() => (localSurvey.status === "draft" ? handleSurveySaveDraft() : handleSurveySave())}
type="submit">
- {t("common.save")}
+ {localSurvey.status === "draft" ? t("common.save_as_draft") : t("common.save")}
)}
-
{localSurvey.status !== "draft" && (
({
@@ -26,6 +27,10 @@ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
+vi.mock("@/lib/survey/service", () => ({
+ updateSurveyInternal: vi.fn(),
+}));
+
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
@@ -692,4 +697,89 @@ describe("Survey Editor Library Tests", () => {
).toThrow(InvalidInputError);
});
});
+
+ describe("updateSurveyDraft", () => {
+ const mockSurvey = {
+ id: "survey123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Draft Survey",
+ type: "app",
+ environmentId: "env123",
+ createdBy: "user123",
+ status: "draft",
+ displayOption: "displayOnce",
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 1" },
+ required: false,
+ inputType: "text",
+ charLimit: { enabled: false },
+ },
+ ],
+ welcomeCard: {
+ enabled: false,
+ timeToFinish: true,
+ showResponseCount: false,
+ },
+ triggers: [],
+ endings: [],
+ hiddenFields: { enabled: false },
+ delay: 0,
+ autoComplete: null,
+ projectOverwrites: null,
+ styling: null,
+ showLanguageSwitch: false,
+ segment: null,
+ surveyClosedMessage: null,
+ singleUse: null,
+ isVerifyEmailEnabled: false,
+ recaptcha: null,
+ isSingleResponsePerEmailEnabled: false,
+ isBackButtonHidden: false,
+ pin: null,
+ displayPercentage: null,
+ languages: [],
+ variables: [],
+ followUps: [],
+ } as unknown as TSurvey;
+
+ beforeEach(() => {
+ vi.mocked(updateSurveyInternal).mockResolvedValue(mockSurvey);
+ });
+
+ test("should call updateSurveyInternal with skipValidation=true", async () => {
+ await updateSurveyDraft(mockSurvey);
+
+ expect(updateSurveyInternal).toHaveBeenCalledWith(mockSurvey, true);
+ expect(updateSurveyInternal).toHaveBeenCalledTimes(1);
+ });
+
+ test("should return the survey from updateSurveyInternal", async () => {
+ const result = await updateSurveyDraft(mockSurvey);
+
+ expect(result).toEqual(mockSurvey);
+ });
+
+ test("should propagate errors from updateSurveyInternal", async () => {
+ const error = new Error("Internal update failed");
+ vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
+
+ await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow("Internal update failed");
+ });
+
+ test("should propagate ResourceNotFoundError from updateSurveyInternal", async () => {
+ vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey123"));
+
+ await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("should propagate DatabaseError from updateSurveyInternal", async () => {
+ vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new DatabaseError("Database connection failed"));
+
+ await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(DatabaseError);
+ });
+ });
});
diff --git a/apps/web/modules/survey/editor/lib/survey.ts b/apps/web/modules/survey/editor/lib/survey.ts
index f5d516f99d..73f9addd37 100644
--- a/apps/web/modules/survey/editor/lib/survey.ts
+++ b/apps/web/modules/survey/editor/lib/survey.ts
@@ -4,12 +4,18 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
+import { updateSurveyInternal } from "@/lib/survey/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
+export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise => {
+ // Use internal version with skipValidation=true to allow incomplete drafts
+ return updateSurveyInternal(updatedSurvey, true);
+};
+
export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
try {
const surveyId = updatedSurvey.id;
diff --git a/apps/web/modules/survey/editor/types/survey.ts b/apps/web/modules/survey/editor/types/survey.ts
new file mode 100644
index 0000000000..e270faf832
--- /dev/null
+++ b/apps/web/modules/survey/editor/types/survey.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+import { ZId } from "@formbricks/types/common";
+import { ZSurveyType } from "@formbricks/types/surveys/types";
+
+/**
+ * Lenient schema for draft survey updates.
+ * Validates essential fields for security/functionality but allows incomplete survey data.
+ * Full validation (ZSurvey) is enforced when publishing.
+ */
+export const ZSurveyDraft = z
+ .object({
+ // Essential fields - strictly validated
+ id: ZId,
+ status: z.literal("draft"),
+ environmentId: ZId,
+ type: ZSurveyType,
+ name: z.string().min(1, "Survey name is required"),
+
+ // Required fields for database operations - loosely validated
+ blocks: z.array(z.record(z.unknown())).optional(),
+ triggers: z.array(z.record(z.unknown())).optional(),
+ endings: z.array(z.record(z.unknown())).optional(),
+ segment: z.record(z.unknown()).nullable().optional(),
+ })
+ .passthrough(); // Allow all other fields without validation
+
+export type TSurveyDraft = z.infer;
From 75cdb25d27269c16578e81063b7309ed7c50d50b Mon Sep 17 00:00:00 2001
From: Johannes <72809645+jobenjada@users.noreply.github.com>
Date: Sun, 14 Dec 2025 00:18:11 -0800
Subject: [PATCH 10/24] fix: improve survey response queue robustness to
prevent data loss (#6959)
Co-authored-by: pandeymangg
---
packages/surveys/locales/ar.json | 1 +
packages/surveys/locales/de.json | 1 +
packages/surveys/locales/en.json | 1 +
packages/surveys/locales/es.json | 1 +
packages/surveys/locales/fr.json | 1 +
packages/surveys/locales/hi.json | 1 +
packages/surveys/locales/it.json | 1 +
packages/surveys/locales/ja.json | 1 +
packages/surveys/locales/nl.json | 1 +
packages/surveys/locales/pt.json | 1 +
packages/surveys/locales/ro.json | 1 +
packages/surveys/locales/ru.json | 1 +
packages/surveys/locales/sv.json | 1 +
packages/surveys/locales/uz.json | 1 +
packages/surveys/locales/zh-Hans.json | 1 +
.../general/response-error-component.tsx | 27 ++++--
.../surveys/src/components/general/survey.tsx | 19 +++--
packages/surveys/src/lib/response-queue.ts | 41 +++++++--
.../surveys/src/lib/response.queue.test.ts | 84 ++++++++++++++++++-
19 files changed, 165 insertions(+), 21 deletions(-)
diff --git a/packages/surveys/locales/ar.json b/packages/surveys/locales/ar.json
index e1b3f39b28..440c1caa4a 100644
--- a/packages/surveys/locales/ar.json
+++ b/packages/surveys/locales/ar.json
@@ -28,6 +28,7 @@
"ranking_items": "عناصر الترتيب",
"respondents_will_not_see_this_card": "لن يرى المستجيبون هذه البطاقة",
"retry": "إعادة المحاولة",
+ "retrying": "إعادة المحاولة...",
"select_a_date": "اختر تاريخًا",
"select_for_ranking": "اختر {item} للترتيب",
"sending_responses": "جارٍ إرسال الردود...",
diff --git a/packages/surveys/locales/de.json b/packages/surveys/locales/de.json
index a4dd99a2b7..b6ae0a3857 100644
--- a/packages/surveys/locales/de.json
+++ b/packages/surveys/locales/de.json
@@ -28,6 +28,7 @@
"ranking_items": "Ranking-Elemente",
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
"retry": "Wiederholen",
+ "retrying": "Wird wiederholt...",
"select_a_date": "Datum auswählen",
"select_for_ranking": "{item} für Ranking auswählen",
"sending_responses": "Antworten werden gesendet...",
diff --git a/packages/surveys/locales/en.json b/packages/surveys/locales/en.json
index 565a4f96bf..d0b6a1b4e9 100644
--- a/packages/surveys/locales/en.json
+++ b/packages/surveys/locales/en.json
@@ -28,6 +28,7 @@
"ranking_items": "Ranking Items",
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
+ "retrying": "Retrying...",
"select_a_date": "Select a date",
"select_for_ranking": "Select {item} for ranking",
"sending_responses": "Sending responses...",
diff --git a/packages/surveys/locales/es.json b/packages/surveys/locales/es.json
index a5676fa55e..0572ad03b0 100644
--- a/packages/surveys/locales/es.json
+++ b/packages/surveys/locales/es.json
@@ -28,6 +28,7 @@
"ranking_items": "Elementos de clasificación",
"respondents_will_not_see_this_card": "Los encuestados no verán esta tarjeta",
"retry": "Reintentar",
+ "retrying": "Reintentando...",
"select_a_date": "Seleccionar una fecha",
"select_for_ranking": "Seleccionar {item} para clasificación",
"sending_responses": "Enviando respuestas...",
diff --git a/packages/surveys/locales/fr.json b/packages/surveys/locales/fr.json
index 5e1e5dc700..646a0e33e8 100644
--- a/packages/surveys/locales/fr.json
+++ b/packages/surveys/locales/fr.json
@@ -28,6 +28,7 @@
"ranking_items": "Éléments de classement",
"respondents_will_not_see_this_card": "Les répondants ne verront pas cette carte",
"retry": "Réessayer",
+ "retrying": "Nouvelle tentative...",
"select_a_date": "Sélectionner une date",
"select_for_ranking": "Sélectionner {item} pour le classement",
"sending_responses": "Envoi des réponses...",
diff --git a/packages/surveys/locales/hi.json b/packages/surveys/locales/hi.json
index 329e0ad361..e39c576c0b 100644
--- a/packages/surveys/locales/hi.json
+++ b/packages/surveys/locales/hi.json
@@ -28,6 +28,7 @@
"ranking_items": "रैंकिंग आइटम",
"respondents_will_not_see_this_card": "उत्तरदाता इस कार्ड को नहीं देखेंगे",
"retry": "पुनः प्रयास करें",
+ "retrying": "पुनः प्रयास कर रहे हैं...",
"select_a_date": "एक तिथि चुनें",
"select_for_ranking": "रैंकिंग के लिए {item} चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
diff --git a/packages/surveys/locales/it.json b/packages/surveys/locales/it.json
index 3c65887902..b71e9694c9 100644
--- a/packages/surveys/locales/it.json
+++ b/packages/surveys/locales/it.json
@@ -28,6 +28,7 @@
"ranking_items": "Elementi di classifica",
"respondents_will_not_see_this_card": "I rispondenti non vedranno questa scheda",
"retry": "Riprova",
+ "retrying": "Riprovando...",
"select_a_date": "Seleziona una data",
"select_for_ranking": "Seleziona {item} per la classifica",
"sending_responses": "Invio risposte in corso...",
diff --git a/packages/surveys/locales/ja.json b/packages/surveys/locales/ja.json
index 9a6f36dab8..1225a8fb80 100644
--- a/packages/surveys/locales/ja.json
+++ b/packages/surveys/locales/ja.json
@@ -28,6 +28,7 @@
"ranking_items": "ランキング項目",
"respondents_will_not_see_this_card": "回答者はこのカードを見ることができません",
"retry": "再試行",
+ "retrying": "再試行中...",
"select_a_date": "日付を選択",
"select_for_ranking": "ランキング用に{item}を選択",
"sending_responses": "回答を送信中...",
diff --git a/packages/surveys/locales/nl.json b/packages/surveys/locales/nl.json
index e360ac109d..4e18cc7445 100644
--- a/packages/surveys/locales/nl.json
+++ b/packages/surveys/locales/nl.json
@@ -28,6 +28,7 @@
"ranking_items": "Items rangschikken",
"respondents_will_not_see_this_card": "Respondenten zien deze kaart niet",
"retry": "Opnieuw proberen",
+ "retrying": "Opnieuw proberen...",
"select_a_date": "Selecteer een datum",
"select_for_ranking": "Selecteer {item} voor rangschikking",
"sending_responses": "Reacties verzenden...",
diff --git a/packages/surveys/locales/pt.json b/packages/surveys/locales/pt.json
index f6ce373e1f..baccecbc62 100644
--- a/packages/surveys/locales/pt.json
+++ b/packages/surveys/locales/pt.json
@@ -28,6 +28,7 @@
"ranking_items": "Itens de classificação",
"respondents_will_not_see_this_card": "Os respondentes não verão este cartão",
"retry": "Tentar novamente",
+ "retrying": "Tentando novamente...",
"select_a_date": "Selecione uma data",
"select_for_ranking": "Selecione {item} para classificação",
"sending_responses": "Enviando respostas...",
diff --git a/packages/surveys/locales/ro.json b/packages/surveys/locales/ro.json
index 1cd36cf5fe..f2c4663c93 100644
--- a/packages/surveys/locales/ro.json
+++ b/packages/surveys/locales/ro.json
@@ -28,6 +28,7 @@
"ranking_items": "Clasificare articole",
"respondents_will_not_see_this_card": "Respondenții nu vor vedea acest card",
"retry": "Reîncearcă",
+ "retrying": "Se reîncearcă...",
"select_a_date": "Selectează o dată",
"select_for_ranking": "Selectează {item} pentru clasificare",
"sending_responses": "Trimiterea răspunsurilor...",
diff --git a/packages/surveys/locales/ru.json b/packages/surveys/locales/ru.json
index 1a3fe2253c..f841174022 100644
--- a/packages/surveys/locales/ru.json
+++ b/packages/surveys/locales/ru.json
@@ -28,6 +28,7 @@
"ranking_items": "Ранжирование элементов",
"respondents_will_not_see_this_card": "Респонденты не увидят эту карточку",
"retry": "Повторить",
+ "retrying": "Повторная попытка...",
"select_a_date": "Выберите дату",
"select_for_ranking": "Выберите {item} для ранжирования",
"sending_responses": "Отправка ответов...",
diff --git a/packages/surveys/locales/sv.json b/packages/surveys/locales/sv.json
index 7daa3738c8..af8e05b9a9 100644
--- a/packages/surveys/locales/sv.json
+++ b/packages/surveys/locales/sv.json
@@ -28,6 +28,7 @@
"ranking_items": "Rangordna objekt",
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
"retry": "Försök igen",
+ "retrying": "Försöker igen...",
"select_a_date": "Välj ett datum",
"select_for_ranking": "Välj {item} för rangordning",
"sending_responses": "Skickar svar...",
diff --git a/packages/surveys/locales/uz.json b/packages/surveys/locales/uz.json
index 88dbef17e1..0826e7958f 100644
--- a/packages/surveys/locales/uz.json
+++ b/packages/surveys/locales/uz.json
@@ -28,6 +28,7 @@
"ranking_items": "Reyting elementlari",
"respondents_will_not_see_this_card": "Javob beruvchilar ushbu kartani ko'rmaydi",
"retry": "Qayta urinib ko'ring",
+ "retrying": "Qayta urinilmoqda...",
"select_a_date": "Sanani tanlang",
"select_for_ranking": "Reyting uchun {item} ni tanlang",
"sending_responses": "Javoblar yuborilmoqda...",
diff --git a/packages/surveys/locales/zh-Hans.json b/packages/surveys/locales/zh-Hans.json
index 8f64313bd7..b6582364bf 100644
--- a/packages/surveys/locales/zh-Hans.json
+++ b/packages/surveys/locales/zh-Hans.json
@@ -28,6 +28,7 @@
"ranking_items": "排名项目",
"respondents_will_not_see_this_card": "受访者将不会看到此卡片",
"retry": "重试",
+ "retrying": "重试中...",
"select_a_date": "选择日期",
"select_for_ranking": "选择{item}进行排名",
"sending_responses": "正在发送响应...",
diff --git a/packages/surveys/src/components/general/response-error-component.tsx b/packages/surveys/src/components/general/response-error-component.tsx
index 8e2a34cba3..f38c6521e6 100644
--- a/packages/surveys/src/components/general/response-error-component.tsx
+++ b/packages/surveys/src/components/general/response-error-component.tsx
@@ -5,12 +5,18 @@ import { SubmitButton } from "@/components/buttons/submit-button";
import { processResponseData } from "@/lib/response";
interface ResponseErrorComponentProps {
- questions: TSurveyElement[];
- responseData: TResponseData;
- onRetry?: () => void;
+ readonly questions: TSurveyElement[];
+ readonly responseData: TResponseData;
+ readonly onRetry?: () => void;
+ readonly isRetrying?: boolean;
}
-export function ResponseErrorComponent({ questions, responseData, onRetry }: ResponseErrorComponentProps) {
+export function ResponseErrorComponent({
+ questions,
+ responseData,
+ onRetry,
+ isRetrying = false,
+}: ResponseErrorComponentProps) {
const { t } = useTranslation();
return (
@@ -23,14 +29,14 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
{t("common.please_retry_now_or_try_again_later")}
-
+
{questions.map((question, index) => {
const response = responseData[question.id];
if (!response) return;
return (
- {`${t("common.question")} ${(index + 1).toString()}`}
-
+ {`${t("common.question")} ${(index + 1).toString()}`}
+
{processResponseData(response)}
@@ -40,11 +46,14 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
{
- onRetry?.();
+ if (!isRetrying) {
+ onRetry?.();
+ }
}}
+ disabled={isRetrying}
/>
diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx
index 837496d663..1b00813d3b 100644
--- a/packages/surveys/src/components/general/survey.tsx
+++ b/packages/surveys/src/components/general/survey.tsx
@@ -110,7 +110,7 @@ export function Survey({
{
appUrl,
environmentId,
- retryAttempts: 2,
+ retryAttempts: 4,
onResponseSendingFailed: (_, errorCode?: TResponseErrorCodesEnum) => {
setShowError(true);
setErrorType(errorCode);
@@ -185,6 +185,7 @@ export function Survey({
const [errorType, setErrorType] = useState
(undefined);
const [showError, setShowError] = useState(false);
+ const [isRetrying, setIsRetrying] = useState(false);
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(
!getSetIsResponseSendingFinished
);
@@ -710,11 +711,16 @@ export function Survey({
setBlockId(prevBlockId);
};
- const retryResponse = () => {
+ const retryResponse = async () => {
if (responseQueue) {
- setShowError(false);
- setErrorType(undefined);
- void responseQueue.processQueue();
+ setIsRetrying(true);
+ const result = await responseQueue.processQueue();
+ setIsRetrying(false);
+
+ if (result.success) {
+ setShowError(false);
+ setErrorType(undefined);
+ }
} else {
onRetry?.();
}
@@ -726,9 +732,10 @@ export function Survey({
case TResponseErrorCodesEnum.ResponseSendingError:
return (
);
case TResponseErrorCodesEnum.RecaptchaError:
diff --git a/packages/surveys/src/lib/response-queue.ts b/packages/surveys/src/lib/response-queue.ts
index 7a4d0dd006..5e9f31f8e4 100644
--- a/packages/surveys/src/lib/response-queue.ts
+++ b/packages/surveys/src/lib/response-queue.ts
@@ -55,8 +55,10 @@ export class ResponseQueue {
this.processQueue();
}
- async processQueue() {
- if (this.isRequestInProgress || this.queue.length === 0) return;
+ async processQueue(): Promise<{ success: boolean }> {
+ if (this.isRequestInProgress || this.queue.length === 0) {
+ return { success: false };
+ }
this.isRequestInProgress = true;
const responseUpdate = this.queue[0];
@@ -65,8 +67,10 @@ export class ResponseQueue {
if (result.success) {
this.handleSuccessfulResponse(responseUpdate, result.quotaFullResponse);
+ return { success: true };
} else {
this.handleFailedResponse(responseUpdate, result.isRecaptchaError);
+ return { success: false };
}
}
@@ -88,18 +92,41 @@ export class ResponseQueue {
quotaFullResponse = res.data;
}
+ if (attempts > 0) {
+ console.log(`Formbricks: Response sent successfully after ${attempts + 1} attempts`);
+ }
+
return { success: true, quotaFullResponse: quotaFullResponse ?? undefined };
}
if (this.isRecaptchaError(res.error)) {
+ console.error("Formbricks: Recaptcha verification failed", {
+ error: res.error,
+ responseId: this.surveyState.responseId,
+ });
return { success: false, isRecaptchaError: true };
}
- console.error(`Formbricks: Failed to send response. Retrying... ${attempts}`);
- await delay(1000);
+ console.error(`Formbricks: Response send failed`, {
+ attempt: attempts + 1,
+ maxAttempts: this.config.retryAttempts,
+ error: res.error,
+ responseId: this.surveyState.responseId,
+ queueLength: this.queue.length,
+ });
+
+ // Exponential backoff: 1s, 2s, 4s, 8s
+ const backoffMs = 1000 * Math.pow(2, attempts);
+ await delay(backoffMs);
attempts++;
}
+ console.error(`Formbricks: Failed to send response after ${this.config.retryAttempts} attempts`, {
+ queueLength: this.queue.length,
+ responseId: this.surveyState.responseId,
+ surveyId: this.surveyState.surveyId,
+ });
+
return { success: false, isRecaptchaError: false };
}
@@ -133,7 +160,6 @@ export class ResponseQueue {
return;
}
- console.error(`Failed to send response after ${this.config.retryAttempts} attempts.`);
this.config.onResponseSendingFailed?.(responseUpdate, TResponseErrorCodesEnum.ResponseSendingError);
}
@@ -198,4 +224,9 @@ export class ResponseQueue {
updateSurveyState(surveyState: SurveyState) {
this.surveyState = surveyState;
}
+
+ // get unsent response data from queue
+ getUnsentData(): TResponseUpdate["data"] {
+ return this.queue.reduce((acc, item) => ({ ...acc, ...item.data }), {});
+ }
}
diff --git a/packages/surveys/src/lib/response.queue.test.ts b/packages/surveys/src/lib/response.queue.test.ts
index 093e6bc825..cf08ee4ba2 100644
--- a/packages/surveys/src/lib/response.queue.test.ts
+++ b/packages/surveys/src/lib/response.queue.test.ts
@@ -82,7 +82,7 @@ describe("ResponseQueue", () => {
});
test("add accumulates response, sets survey state, and processes queue", async () => {
- vi.spyOn(queue, "processQueue").mockImplementation(() => Promise.resolve());
+ vi.spyOn(queue, "processQueue").mockImplementation(() => Promise.resolve({ success: true }));
queue.add(responseUpdate);
expect(surveyState.accumulateResponse).toHaveBeenCalledWith(responseUpdate);
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
@@ -192,4 +192,86 @@ describe("ResponseQueue", () => {
queue.updateSurveyState(newState);
expect(queue["surveyState"]).toBe(newState);
});
+
+ test("processQueueAsync returns success false if queue empty", async () => {
+ const result = await queue.processQueue();
+ expect(result.success).toBe(false);
+ });
+
+ test("processQueueAsync returns success false if request in progress", async () => {
+ queue["isRequestInProgress"] = true;
+ const result = await queue.processQueue();
+ expect(result.success).toBe(false);
+ });
+
+ test("processQueueAsync returns success true on successful send", async () => {
+ queue.queue.push(responseUpdate);
+ vi.spyOn(queue, "sendResponse").mockResolvedValue(ok(true));
+ const result = await queue.processQueue();
+ expect(result.success).toBe(true);
+ expect(queue.queue.length).toBe(0);
+ });
+
+ test("processQueueAsync returns success false after max attempts", async () => {
+ queue.queue.push(responseUpdate);
+ vi.spyOn(queue, "sendResponse").mockResolvedValue(
+ err({
+ code: "internal_server_error",
+ message: "An error occurred while sending the response.",
+ status: 500,
+ })
+ );
+ const result = await queue.processQueue();
+ expect(result.success).toBe(false);
+ expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
+ responseUpdate,
+ TResponseErrorCodesEnum.ResponseSendingError
+ );
+ });
+
+ test("processQueueAsync returns success false on recaptcha error", async () => {
+ queue.queue.push(responseUpdate);
+ vi.spyOn(queue, "sendResponse").mockResolvedValue(
+ err({
+ code: "internal_server_error",
+ message: "An error occurred while sending the response.",
+ status: 500,
+ details: {
+ code: "recaptcha_verification_failed",
+ },
+ })
+ );
+ const result = await queue.processQueue();
+ expect(result.success).toBe(false);
+ expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
+ responseUpdate,
+ TResponseErrorCodesEnum.RecaptchaError
+ );
+ });
+
+ test("getUnsentData returns empty object when queue is empty", () => {
+ const unsentData = queue.getUnsentData();
+ expect(unsentData).toEqual({});
+ });
+
+ test("getUnsentData returns data from single item in queue", () => {
+ queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
+ const unsentData = queue.getUnsentData();
+ expect(unsentData).toEqual({ q1: "answer1" });
+ });
+
+ test("getUnsentData aggregates data from multiple items in queue", () => {
+ queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
+ queue.queue.push({ data: { q2: "answer2" }, hiddenFields: {}, finished: false });
+ queue.queue.push({ data: { q3: "answer3" }, hiddenFields: {}, finished: true });
+ const unsentData = queue.getUnsentData();
+ expect(unsentData).toEqual({ q1: "answer1", q2: "answer2", q3: "answer3" });
+ });
+
+ test("getUnsentData overwrites duplicate keys with latest value", () => {
+ queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
+ queue.queue.push({ data: { q1: "updated_answer1", q2: "answer2" }, hiddenFields: {}, finished: false });
+ const unsentData = queue.getUnsentData();
+ expect(unsentData).toEqual({ q1: "updated_answer1", q2: "answer2" });
+ });
});
From ba2070b638fd261f598f677339a6602ce547f14b Mon Sep 17 00:00:00 2001
From: Johannes <72809645+jobenjada@users.noreply.github.com>
Date: Sun, 14 Dec 2025 01:09:43 -0800
Subject: [PATCH 11/24] feat: add vars & hidden fields + send to verified email
to followups (#6874)
Co-authored-by: pandeymangg
---
apps/web/i18n.lock | 11 +-
apps/web/locales/de-DE.json | 9 +-
apps/web/locales/en-US.json | 8 +-
apps/web/locales/es-ES.json | 9 +-
apps/web/locales/fr-FR.json | 11 +-
apps/web/locales/ja-JP.json | 9 +-
apps/web/locales/nl-NL.json | 11 +-
apps/web/locales/pt-BR.json | 11 +-
apps/web/locales/pt-PT.json | 11 +-
apps/web/locales/ro-RO.json | 9 +-
apps/web/locales/sv-SE.json | 7 +-
apps/web/locales/zh-Hans-CN.json | 11 +-
apps/web/locales/zh-Hant-TW.json | 9 +-
apps/web/modules/email/emails/lib/utils.tsx | 15 +-
.../emails/survey/response-finished-email.tsx | 32 ++--
.../settings/(setup)/app-connection/page.tsx | 2 +-
.../survey/editor/types/survey-follow-up.ts | 2 +
.../follow-ups/components/follow-up-email.tsx | 174 ++++++++----------
.../follow-ups/components/follow-up-item.tsx | 7 +
.../follow-ups/components/follow-up-modal.tsx | 153 +++++++++++----
.../modules/survey/follow-ups/lib/email.ts | 6 +
.../survey/follow-ups/lib/follow-ups.ts | 6 +
.../general-features/email-followups.mdx | 1 +
packages/database/types/survey-follow-up.ts | 2 +
packages/types/surveys/types.ts | 1 +
25 files changed, 342 insertions(+), 185 deletions(-)
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 247d85eb9b..84d49b713e 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -311,7 +311,7 @@ checksums:
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
- common/read_docs: 426ba960bfedf186a878b7467867f9d2
+ common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
@@ -324,7 +324,6 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f
- common/role_organization: e7dbf80450ceac1c6c22ba5602ea7e66
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
@@ -445,6 +444,7 @@ checksums:
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
+ emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
@@ -456,12 +456,14 @@ checksums:
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
+ emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06
emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78
emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1
emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
emails/reject: 417c19f66db70a0548bdeb398cdc46e0
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
+ emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
@@ -473,6 +475,7 @@ checksums:
emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6
emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd
emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94
+ emails/text_variable: 5fdfcc48b8010a4f44e16b8051272a75
emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc
emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0
emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194
@@ -1306,11 +1309,13 @@ checksums:
environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e
environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e
environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff
+ environments/surveys/edit/follow_ups_include_hidden_fields: 8f0c2f8ddd3b95a3e7456a42be9362bb
+ environments/surveys/edit/follow_ups_include_variables: 2604dd580ceafec167ff9136d800f31e
environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b
environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8
environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240
environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec
- environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
+ environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: 901a493d60331420da61d0e76bf07eae
environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index c35023bbbe..cac07f92f8 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -351,7 +351,6 @@
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
- "role_organization": "Rolle (Organisation)",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
+ "hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
+ "number_variable": "Zahlenvariable",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
+ "response_data": "Antwortdaten",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
"schedule_your_meeting": "Termin planen",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Benachrichtigungen für dieses Formular ausschalten",
"survey_response_finished_email_view_more_responses": "Zeige {responseCount} weitere Antworten",
"survey_response_finished_email_view_survey_summary": "Umfragezusammenfassung anzeigen",
+ "text_variable": "Textvariable",
"verification_email_click_on_this_link": "Du kannst auch auf diesen Link klicken:",
"verification_email_heading": "Fast geschafft!",
"verification_email_hey": "Hey 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
+ "follow_ups_include_hidden_fields": "Werte versteckter Felder einbeziehen",
+ "follow_ups_include_variables": "Variablenwerte einbeziehen",
"follow_ups_item_ending_tag": "Abschluss",
"follow_ups_item_issue_detected_tag": "Problem erkannt",
"follow_ups_item_response_tag": "Jede Antwort",
"follow_ups_item_send_email_tag": "E-Mail senden",
- "follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
+ "follow_ups_modal_action_attach_response_data_description": "Fügt nur die Fragen bei, die in der Umfrageantwort beantwortet wurden",
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
"follow_ups_modal_action_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index a06cc6fb20..a93ff7139f 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -473,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
+ "hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "You've got a new organization member!",
@@ -484,12 +485,14 @@
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
+ "number_variable": "Number variable",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
+ "response_data": "Response data",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
@@ -501,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"survey_response_finished_email_view_survey_summary": "View survey summary",
+ "text_variable": "Text variable",
"verification_email_click_on_this_link": "You can also click on this link:",
"verification_email_heading": "Almost there!",
"verification_email_hey": "Hey \uD83D\uDC4B",
@@ -1390,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
+ "follow_ups_include_hidden_fields": "Include hidden field values",
+ "follow_ups_include_variables": "Include variable values",
"follow_ups_item_ending_tag": "Ending(s)",
"follow_ups_item_issue_detected_tag": "Issue detected",
"follow_ups_item_response_tag": "Any response",
"follow_ups_item_send_email_tag": "Send email",
- "follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up",
+ "follow_ups_modal_action_attach_response_data_description": "Attaches only the questions that were answered in the survey response",
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index e40be6686d..8993221a33 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -351,7 +351,6 @@
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
- "role_organization": "Rol (organización)",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
+ "hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
+ "number_variable": "Variable numérica",
"password_changed_email_heading": "Contraseña cambiada",
"password_changed_email_text": "Tu contraseña se ha cambiado correctamente.",
"password_reset_notify_email_subject": "Tu contraseña de Formbricks ha sido cambiada",
"privacy_policy": "Política de privacidad",
"reject": "Rechazar",
"render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos",
+ "response_data": "Datos de respuesta",
"response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅",
"response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅",
"schedule_your_meeting": "Programa tu reunión",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desactivar notificaciones para este formulario",
"survey_response_finished_email_view_more_responses": "Ver {responseCount} respuestas más",
"survey_response_finished_email_view_survey_summary": "Ver resumen de la encuesta",
+ "text_variable": "Variable de texto",
"verification_email_click_on_this_link": "También puedes hacer clic en este enlace:",
"verification_email_heading": "¡Ya casi está!",
"verification_email_hey": "Hola 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Esta tarjeta de finalización se utiliza en seguimientos. Al eliminarla se quitará de todos los seguimientos. ¿Estás seguro de que quieres eliminarla?",
"follow_ups_ending_card_delete_modal_title": "¿Eliminar tarjeta de finalización?",
"follow_ups_hidden_field_error": "El campo oculto se utiliza en un seguimiento. Por favor, elimínalo primero del seguimiento.",
+ "follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
+ "follow_ups_include_variables": "Incluir valores de variables",
"follow_ups_item_ending_tag": "Finalización(es)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Cualquier respuesta",
"follow_ups_item_send_email_tag": "Enviar correo electrónico",
- "follow_ups_modal_action_attach_response_data_description": "Añadir los datos de la respuesta de la encuesta al seguimiento",
+ "follow_ups_modal_action_attach_response_data_description": "Adjunta solo las preguntas que fueron respondidas en la respuesta de la encuesta",
"follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta",
"follow_ups_modal_action_body_label": "Cuerpo",
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 2ed10a96f4..c8a9d81813 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -338,7 +338,7 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
- "read_docs": "Lire les documents",
+ "read_docs": "Lire la documentation",
"recipients": "Destinataires",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
@@ -351,7 +351,6 @@
"responses": "Réponses",
"restart": "Recommencer",
"role": "Rôle",
- "role_organization": "Rôle (Organisation)",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
+ "hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
+ "number_variable": "Variable numérique",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
+ "response_data": "Données de réponse",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Désactiver les notifications pour ce formulaire",
"survey_response_finished_email_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"survey_response_finished_email_view_survey_summary": "Voir le résumé de l'enquête",
+ "text_variable": "Variable texte",
"verification_email_click_on_this_link": "Vous pouvez également cliquer sur ce lien :",
"verification_email_heading": "Presque là !",
"verification_email_hey": "Salut 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Cette carte de fin est utilisée dans les suivis. La supprimer la retirera de tous les suivis. Êtes-vous sûr de vouloir la supprimer ?",
"follow_ups_ending_card_delete_modal_title": "Supprimer la carte de fin ?",
"follow_ups_hidden_field_error": "Le champ caché est utilisé dans un suivi. Veuillez d'abord le supprimer du suivi.",
+ "follow_ups_include_hidden_fields": "Inclure les valeurs des champs cachés",
+ "follow_ups_include_variables": "Inclure les valeurs des variables",
"follow_ups_item_ending_tag": "Fin(s)",
"follow_ups_item_issue_detected_tag": "Problème détecté",
"follow_ups_item_response_tag": "Une réponse quelconque",
"follow_ups_item_send_email_tag": "Envoyer un e-mail",
- "follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi",
+ "follow_ups_modal_action_attach_response_data_description": "Joint uniquement les questions auxquelles on a répondu dans la réponse au sondage",
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
"follow_ups_modal_action_body_label": "Corps",
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index 0b472edf14..4365eb013c 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -351,7 +351,6 @@
"responses": "回答",
"restart": "再開",
"role": "役割",
- "role_organization": "役割(組織)",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
+ "hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました!",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
+ "number_variable": "数値変数",
"password_changed_email_heading": "パスワードが変更されました",
"password_changed_email_text": "パスワードが正常に変更されました。",
"password_reset_notify_email_subject": "Formbricksのパスワードが変更されました",
"privacy_policy": "プライバシーポリシー",
"reject": "拒否",
"render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません",
+ "response_data": "回答データ",
"response_finished_email_subject": "{surveyName} の回答が完了しました ✅",
"response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅",
"schedule_your_meeting": "ミーティングを予約",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "このフォームの通知をオフにする",
"survey_response_finished_email_view_more_responses": "さらに {responseCount} 件の回答を見る",
"survey_response_finished_email_view_survey_summary": "フォームの概要を見る",
+ "text_variable": "テキスト変数",
"verification_email_click_on_this_link": "このリンクをクリックすることもできます:",
"verification_email_heading": "あと少しです!",
"verification_email_hey": "こんにちは 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "この終了カードはフォローアップで使用されています。これを削除すると、すべてのフォローアップから削除されます。本当に削除しますか?",
"follow_ups_ending_card_delete_modal_title": "終了カードを削除しますか?",
"follow_ups_hidden_field_error": "非表示フィールドはフォローアップで使用されています。まず、フォローアップから削除してください。",
+ "follow_ups_include_hidden_fields": "非表示フィールドの値を含める",
+ "follow_ups_include_variables": "変数の値を含める",
"follow_ups_item_ending_tag": "終了",
"follow_ups_item_issue_detected_tag": "問題が検出されました",
"follow_ups_item_response_tag": "任意の回答",
"follow_ups_item_send_email_tag": "メールを送信",
- "follow_ups_modal_action_attach_response_data_description": "フォームの回答データをフォローアップに追加する",
+ "follow_ups_modal_action_attach_response_data_description": "アンケート回答で答えられた質問のみを添付します",
"follow_ups_modal_action_attach_response_data_label": "回答データを添付",
"follow_ups_modal_action_body_label": "本文",
"follow_ups_modal_action_body_placeholder": "メールの本文",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index 6828e01700..ddb3fd06f7 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -338,7 +338,7 @@
"quota": "Quotum",
"quotas": "Quota",
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
- "read_docs": "Lees Documenten",
+ "read_docs": "Documentatie lezen",
"recipients": "Ontvangers",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
@@ -351,7 +351,6 @@
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
- "role_organization": "Rol (organisatie)",
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
+ "hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
+ "number_variable": "Numerieke variabele",
"password_changed_email_heading": "Wachtwoord gewijzigd",
"password_changed_email_text": "Uw wachtwoord is succesvol gewijzigd.",
"password_reset_notify_email_subject": "Uw Formbricks-wachtwoord is gewijzigd",
"privacy_policy": "Privacybeleid",
"reject": "Afwijzen",
"render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen",
+ "response_data": "Responsgegevens",
"response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅",
"response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅",
"schedule_your_meeting": "Plan uw vergadering",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Schakel meldingen voor dit formulier uit",
"survey_response_finished_email_view_more_responses": "Bekijk nog {responseCount} reacties",
"survey_response_finished_email_view_survey_summary": "Bekijk de samenvatting van het onderzoek",
+ "text_variable": "Tekstvariabele",
"verification_email_click_on_this_link": "U kunt ook op deze link klikken:",
"verification_email_heading": "Bijna daar!",
"verification_email_hey": "Hé 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Deze eindkaart wordt gebruikt bij vervolgacties. Als u het verwijdert, wordt het uit alle vervolgacties verwijderd. Weet je zeker dat je het wilt verwijderen?",
"follow_ups_ending_card_delete_modal_title": "Eindkaart verwijderen?",
"follow_ups_hidden_field_error": "Verborgen veld wordt gebruikt in een follow-up. Verwijder het eerst uit de follow-up.",
+ "follow_ups_include_hidden_fields": "Inclusief waarden van verborgen velden",
+ "follow_ups_include_variables": "Inclusief variabele waarden",
"follow_ups_item_ending_tag": "Einde(n)",
"follow_ups_item_issue_detected_tag": "Probleem gedetecteerd",
"follow_ups_item_response_tag": "Enige reactie",
"follow_ups_item_send_email_tag": "E-mail verzenden",
- "follow_ups_modal_action_attach_response_data_description": "Voeg de gegevens van de enquêtereactie toe aan de follow-up",
+ "follow_ups_modal_action_attach_response_data_description": "Voegt alleen de vragen toe die zijn beantwoord in de enquêterespons",
"follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen",
"follow_ups_modal_action_body_label": "Lichaam",
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 7aca515c9a..8c5629197a 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -338,7 +338,7 @@
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
- "read_docs": "Ler Documentação",
+ "read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "remover",
"remove_from_team": "Remover da equipe",
@@ -351,7 +351,6 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Rolê",
- "role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
+ "hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
+ "number_variable": "Variável numérica",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
+ "response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo da pesquisa",
+ "text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Você também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Oi 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
+ "follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
+ "follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Final(is)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar e-mail",
- "follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
+ "follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta da pesquisa",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 9fcf2928f7..6bb1d1fe1c 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -338,7 +338,7 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
- "read_docs": "Ler Documentos",
+ "read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
@@ -351,7 +351,6 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Função",
- "role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
+ "hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
+ "number_variable": "Variável numérica",
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
+ "response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
"schedule_your_meeting": "Agende a sua reunião",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo do inquérito",
+ "text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Olá 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Este cartão de encerramento é utilizado em seguimentos. Eliminá-lo irá removê-lo de todos os seguimentos. Tem a certeza de que deseja eliminá-lo?",
"follow_ups_ending_card_delete_modal_title": "Eliminar cartão de encerramento?",
"follow_ups_hidden_field_error": "O campo oculto é usado num seguimento. Por favor, remova-o do seguimento primeiro.",
+ "follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
+ "follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Encerramento(s)",
"follow_ups_item_issue_detected_tag": "Problema detetado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar email",
- "follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento",
+ "follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta ao inquérito",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do email",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index df8843374f..34b31c4887 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -351,7 +351,6 @@
"responses": "Răspunsuri",
"restart": "Repornește",
"role": "Rolul",
- "role_organization": "Rol (Organizație)",
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
+ "hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
+ "number_variable": "Variabilă numerică",
"password_changed_email_heading": "Parola modificată",
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
"privacy_policy": "Politica de confidențialitate",
"reject": "Respinge",
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
+ "response_data": "Datele răspunsului",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
"response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅",
"schedule_your_meeting": "Programați întâlnirea",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Dezactivează notificările pentru acest formular",
"survey_response_finished_email_view_more_responses": "Vizualizați {responseCount} mai multe răspunsuri",
"survey_response_finished_email_view_survey_summary": "Vizualizați sumarul sondajului",
+ "text_variable": "Variabilă text",
"verification_email_click_on_this_link": "De asemenea, puteți face clic pe acest link:",
"verification_email_heading": "Aproape gata!",
"verification_email_hey": "Salut 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
+ "follow_ups_include_hidden_fields": "Include valorile câmpurilor ascunse",
+ "follow_ups_include_variables": "Include valorile variabilelor",
"follow_ups_item_ending_tag": "Finalizare",
"follow_ups_item_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns",
"follow_ups_item_send_email_tag": "Trimite email",
- "follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
+ "follow_ups_modal_action_attach_response_data_description": "Atașează doar întrebările la care s-a răspuns în răspunsul sondajului",
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index b259cfbe9c..06b365e614 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -351,7 +351,6 @@
"responses": "Svar",
"restart": "Starta om",
"role": "Roll",
- "role_organization": "Roll (Organisation)",
"saas": "SaaS",
"sales": "Försäljning",
"save": "Spara",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i 24 timmar.",
"forgot_password_email_subject": "Återställ ditt Formbricks-lösenord",
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
+ "hidden_field": "Dolt fält",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hej",
"invite_accepted_email_subject": "Du har fått en ny organisationsmedlem!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "bjöd in dig att gå med dem på Formbricks. För att acceptera inbjudan, vänligen klicka på länken nedan:",
"invite_member_email_subject": "Du är inbjuden att samarbeta på Formbricks!",
"new_email_verification_text": "För att verifiera din nya e-postadress, vänligen klicka på knappen nedan:",
+ "number_variable": "Nummervariabel",
"password_changed_email_heading": "Lösenord ändrat",
"password_changed_email_text": "Ditt lösenord har ändrats.",
"password_reset_notify_email_subject": "Ditt Formbricks-lösenord har ändrats",
"privacy_policy": "Integritetspolicy",
"reject": "Avvisa",
"render_email_response_value_file_upload_response_link_not_included": "Länk till uppladdad fil ingår inte av dataskyddsskäl",
+ "response_data": "Svarsdata",
"response_finished_email_subject": "Ett svar för {surveyName} har slutförts ✅",
"response_finished_email_subject_with_email": "{personEmail} har precis slutfört din {surveyName}-enkät ✅",
"schedule_your_meeting": "Boka ditt möte",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Stäng av aviseringar för detta formulär",
"survey_response_finished_email_view_more_responses": "Visa {responseCount} fler svar",
"survey_response_finished_email_view_survey_summary": "Visa enkätsammanfattning",
+ "text_variable": "Textvariabel",
"verification_email_click_on_this_link": "Du kan också klicka på denna länk:",
"verification_email_heading": "Nästan där!",
"verification_email_hey": "Hej 👋",
@@ -1391,6 +1394,8 @@
"follow_ups_ending_card_delete_modal_text": "Detta avslutningskort används i uppföljningar. Att ta bort det kommer att ta bort det från alla uppföljningar. Är du säker på att du vill ta bort det?",
"follow_ups_ending_card_delete_modal_title": "Ta bort avslutningskort?",
"follow_ups_hidden_field_error": "Dolt fält används i en uppföljning. Vänligen ta bort det från uppföljningen först.",
+ "follow_ups_include_hidden_fields": "Inkludera värden för dolda fält",
+ "follow_ups_include_variables": "Inkludera värden för variabler",
"follow_ups_item_ending_tag": "Avslutning(ar)",
"follow_ups_item_issue_detected_tag": "Problem upptäckt",
"follow_ups_item_response_tag": "Alla svar",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index cb896bd13c..32e2116614 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -338,7 +338,7 @@
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
- "read_docs": "阅读 文档",
+ "read_docs": "阅读文档",
"recipients": "收件人",
"remove": "移除",
"remove_from_team": "从团队中移除",
@@ -351,7 +351,6 @@
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
- "role_organization": "角色 (组织)",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
+ "hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks!",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 :",
+ "number_variable": "数字变量",
"password_changed_email_heading": "密码 已更改",
"password_changed_email_text": "您的 密码已成功更改",
"password_reset_notify_email_subject": "您的 Formbricks 密码已更改",
"privacy_policy": "隐私政策",
"reject": "拒绝",
"render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因",
+ "response_data": "响应数据",
"response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅",
"schedule_your_meeting": "安排你的会议",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "关闭 此表单 的通知",
"survey_response_finished_email_view_more_responses": "查看 {responseCount} 更多 响应",
"survey_response_finished_email_view_survey_summary": "查看 问卷 摘要",
+ "text_variable": "文本变量",
"verification_email_click_on_this_link": "您 也 可以 点击 此 链接:",
"verification_email_heading": "马上就好!",
"verification_email_hey": "嗨 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "此结束卡片 用于 后续跟踪. 删除 它 将会 从 所有 后续跟踪 中 移除. 确定 要 删除 它 吗?",
"follow_ups_ending_card_delete_modal_title": "删除 结尾卡片?",
"follow_ups_hidden_field_error": "隐藏 字段 用于 后续 。请 先 从 后续 中 移除 它 。",
+ "follow_ups_include_hidden_fields": "包括隐藏字段值",
+ "follow_ups_include_variables": "包括变量值",
"follow_ups_item_ending_tag": "结尾",
"follow_ups_item_issue_detected_tag": "问题 检测",
"follow_ups_item_response_tag": "任何 响应",
"follow_ups_item_send_email_tag": "发送 邮件",
- "follow_ups_modal_action_attach_response_data_description": "添加 调查 响应 数据 到 跟进",
+ "follow_ups_modal_action_attach_response_data_description": "仅附加调查响应中已回答的问题",
"follow_ups_modal_action_attach_response_data_label": "附加响应数据",
"follow_ups_modal_action_body_label": "正文",
"follow_ups_modal_action_body_placeholder": "电子邮件正文",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index c15c833a35..65cb8db524 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -351,7 +351,6 @@
"responses": "回應",
"restart": "重新開始",
"role": "角色",
- "role_organization": "角色(組織)",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
@@ -474,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
+ "hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "您有一位新的組織成員!",
@@ -485,12 +485,14 @@
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
"invite_member_email_subject": "您被邀請協作 Formbricks!",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
+ "number_variable": "數字變數",
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
+ "response_data": "回應資料",
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
"schedule_your_meeting": "安排你的會議",
@@ -502,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "關閉此表單的通知",
"survey_response_finished_email_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"survey_response_finished_email_view_survey_summary": "檢視問卷摘要",
+ "text_variable": "文字變數",
"verification_email_click_on_this_link": "您也可以點擊此連結:",
"verification_email_heading": "快完成了!",
"verification_email_hey": "嗨 👋",
@@ -1391,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "此結尾卡片用於後續追蹤中。刪除它將會從所有後續追蹤中移除。您確定要刪除它嗎?",
"follow_ups_ending_card_delete_modal_title": "刪除結尾卡片?",
"follow_ups_hidden_field_error": "隱藏欄位在後續追蹤中使用。請先從後續追蹤中移除。",
+ "follow_ups_include_hidden_fields": "包含隱藏欄位的值",
+ "follow_ups_include_variables": "包含變數的值",
"follow_ups_item_ending_tag": "結尾",
"follow_ups_item_issue_detected_tag": "偵測到問題",
"follow_ups_item_response_tag": "任何回應",
"follow_ups_item_send_email_tag": "發送電子郵件",
- "follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續",
+ "follow_ups_modal_action_attach_response_data_description": "僅附加在調查回應中回答過的問題",
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
"follow_ups_modal_action_body_label": "內文",
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
diff --git a/apps/web/modules/email/emails/lib/utils.tsx b/apps/web/modules/email/emails/lib/utils.tsx
index aeda646ae0..13903ce0aa 100644
--- a/apps/web/modules/email/emails/lib/utils.tsx
+++ b/apps/web/modules/email/emails/lib/utils.tsx
@@ -54,15 +54,12 @@ export const renderEmailResponseValue = async (
{Array.isArray(response) &&
- response.map(
- (item, index) =>
- item && (
-
- #{index + 1}
- {item}
-
- )
- )}
+ response.filter(Boolean).map((item, index) => (
+
+ #{index + 1}
+ {item}
+
+ ))}
);
diff --git a/apps/web/modules/email/emails/survey/response-finished-email.tsx b/apps/web/modules/email/emails/survey/response-finished-email.tsx
index 3c9adac87f..73688ad89d 100644
--- a/apps/web/modules/email/emails/survey/response-finished-email.tsx
+++ b/apps/web/modules/email/emails/survey/response-finished-email.tsx
@@ -52,9 +52,17 @@ export async function ResponseFinishedEmail({
);
})}
- {survey.variables.map((variable) => {
- const variableResponse = response.variables[variable.id];
- if (variableResponse && ["number", "string"].includes(typeof variable)) {
+ {survey.variables
+ .filter((variable) => {
+ const variableResponse = response.variables[variable.id];
+ if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
+ return false;
+ }
+
+ return variableResponse !== undefined;
+ })
+ .map((variable) => {
+ const variableResponse = response.variables[variable.id];
return (
@@ -72,12 +80,14 @@ export async function ResponseFinishedEmail({
);
- }
- return null;
- })}
- {survey.hiddenFields.fieldIds?.map((hiddenFieldId) => {
- const hiddenFieldResponse = response.data[hiddenFieldId];
- if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {
+ })}
+ {survey.hiddenFields.fieldIds
+ ?.filter((hiddenFieldId) => {
+ const hiddenFieldResponse = response.data[hiddenFieldId];
+ return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
+ })
+ .map((hiddenFieldId) => {
+ const hiddenFieldResponse = response.data[hiddenFieldId] as string;
return (
@@ -90,9 +100,7 @@ export async function ResponseFinishedEmail({
);
- }
- return null;
- })}
+ })}
;
diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
index faebbce549..e3aecc0795 100644
--- a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
+++ b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
@@ -1,34 +1,21 @@
-import {
- Body,
- Column,
- Container,
- Hr,
- Html,
- Img,
- Link,
- Row,
- Section,
- Tailwind,
- Text,
-} from "@react-email/components";
+import { Column, Hr, Row, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
-import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
+import { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
-const fbLogoUrl = FB_LOGO_URL;
-const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
-
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
+ readonly includeVariables: boolean;
+ readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
@@ -42,91 +29,92 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise
-
-
-
- {isDefaultLogo ? (
-
-
-
- ) : (
-
- )}
-
-
-
+
+ <>
+
- {elements.length > 0 ?
: null}
+ {elements.length > 0 ? (
+ <>
+
+ {t("emails.response_data")}
+ >
+ ) : null}
- {elements.map((e) => {
- if (!e.response) return;
+ {elements.map((e) => {
+ if (!e.response) return;
+ return (
+
+
+ {e.element}
+ {renderEmailResponseValue(e.response, e.type, t, true)}
+
+
+ );
+ })}
+
+ {props.attachResponseData &&
+ props.includeVariables &&
+ props.survey.variables
+ .filter((variable) => {
+ const variableResponse = props.response.variables[variable.id];
+ if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
+ return false;
+ }
+
+ return variableResponse !== undefined;
+ })
+ .map((variable) => {
+ const variableResponse = props.response.variables[variable.id];
return (
-
-
- {e.element}
- {renderEmailResponseValue(e.response, e.type, t, true)}
+
+
+
+ {variable.type === "number"
+ ? `${t("emails.number_variable")}: ${variable.name}`
+ : `${t("emails.text_variable")}: ${variable.name}`}
+
+
+ {variableResponse}
+
);
})}
-
- {/* If the logo is not set, we are not using white labeling */}
- {isDefaultLogo ? (
-
-
- {t("emails.email_template_text_1")}
-
- {IMPRINT_ADDRESS && (
- {IMPRINT_ADDRESS}
- )}
-
- {IMPRINT_URL && (
-
- {t("emails.imprint")}
-
- )}
- {IMPRINT_URL && PRIVACY_URL && " • "}
- {PRIVACY_URL && (
-
- {t("emails.privacy_policy")}
-
- )}
-
-
- ) : null}
-
-
-