-
+
+
-
+
diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts
index ed794676f2..526815c1a5 100644
--- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts
+++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts
@@ -5,6 +5,7 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
+import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -151,6 +152,23 @@ export const PUT = withV1ApiWrapper({
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
auditLog.newObject = updated;
+
+ sendToPipeline({
+ event: "responseUpdated",
+ environmentId: result.survey.environmentId,
+ surveyId: result.survey.id,
+ response: updated,
+ });
+
+ if (updated.finished) {
+ sendToPipeline({
+ event: "responseFinished",
+ environmentId: result.survey.environmentId,
+ surveyId: result.survey.id,
+ response: updated,
+ });
+ }
+
return {
response: responses.successResponse(updated),
};
diff --git a/apps/web/app/api/v1/management/responses/route.ts b/apps/web/app/api/v1/management/responses/route.ts
index 85518108e3..0b29bdea21 100644
--- a/apps/web/app/api/v1/management/responses/route.ts
+++ b/apps/web/app/api/v1/management/responses/route.ts
@@ -5,6 +5,7 @@ import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/res
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
+import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -156,6 +157,23 @@ export const POST = withV1ApiWrapper({
const response = await createResponseWithQuotaEvaluation(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
+
+ sendToPipeline({
+ event: "responseCreated",
+ environmentId: surveyResult.survey.environmentId,
+ surveyId: response.surveyId,
+ response: response,
+ });
+
+ if (response.finished) {
+ sendToPipeline({
+ event: "responseFinished",
+ environmentId: surveyResult.survey.environmentId,
+ surveyId: response.surveyId,
+ response: response,
+ });
+ }
+
return {
response: responses.successResponse(response, true),
};
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 5d6f28a66a..fe044497c9 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -173,6 +173,7 @@ checksums:
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
+ common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
@@ -182,6 +183,8 @@ checksums:
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
+ common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
+ common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
@@ -328,6 +331,7 @@ checksums:
common/segments: 271db72d5b973fbc5fadab216177eaae
common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2
+ common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -821,7 +825,6 @@ checksums:
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
- environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
@@ -1573,6 +1576,8 @@ checksums:
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
+ environments/surveys/responses/an_error_occurred_adding_the_tag: f211ea1ceb8a93b415d88a8deed874ef
+ environments/surveys/responses/an_error_occurred_creating_the_tag: 89689815f8aff6ff3ba821ab599c540c
environments/surveys/responses/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
@@ -1769,7 +1774,6 @@ checksums:
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68
- environments/surveys/summary/show_all_responses_where: 370a56de4692a588f7ebdbf7f1e28f6f
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index ee7648410e..077878cd99 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -200,6 +200,7 @@
"edit": "Bearbeiten",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
+ "enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
+ "failed_to_load_organizations": "Fehler beim Laden der Organisationen",
+ "failed_to_load_projects": "Fehler beim Laden der Projekte",
"finish": "Fertigstellen",
"follow_these": "Folge diesen",
"formbricks_version": "Formbricks Version",
@@ -355,6 +358,7 @@
"segments": "Segmente",
"select": "Auswählen",
"select_all": "Alles auswählen",
+ "select_filter": "Filter auswählen",
"select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
@@ -886,7 +890,6 @@
"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."
}
},
- "projects_environments_organizations_not_found": "Projekte, Umgebungen oder Organisationen nicht gefunden",
"segments": {
"add_filter_below": "Filter unten hinzufügen",
"add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "Adresszeile 1",
"address_line_2": "Adresszeile 2",
+ "an_error_occurred_adding_the_tag": "Beim Hinzufügen des Tags ist ein Fehler aufgetreten",
+ "an_error_occurred_creating_the_tag": "Beim Erstellen des Tags ist ein Fehler aufgetreten",
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
"browser": "Browser",
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "Integrationen einrichten",
"share_survey": "Umfrage teilen",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
- "show_all_responses_where": "Zeige alle Antworten, bei denen...",
"starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index f69ebed7ca..3a6cf7bfe0 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -200,6 +200,7 @@
"edit": "Edit",
"email": "Email",
"ending_card": "Ending card",
+ "enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
+ "failed_to_load_organizations": "Failed to load organizations",
+ "failed_to_load_projects": "Failed to load projects",
"finish": "Finish",
"follow_these": "Follow these",
"formbricks_version": "Formbricks Version",
@@ -355,6 +358,7 @@
"segments": "Segments",
"select": "Select",
"select_all": "Select all",
+ "select_filter": "Select filter",
"select_survey": "Select Survey",
"select_teams": "Select teams",
"selected": "Selected",
@@ -886,7 +890,6 @@
"team_settings_description": "See which teams can access this project."
}
},
- "projects_environments_organizations_not_found": "Projects, environments or organizations not found",
"segments": {
"add_filter_below": "Add filter below",
"add_your_first_filter_to_get_started": "Add your first filter to get started",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "Address Line 1",
"address_line_2": "Address Line 2",
+ "an_error_occurred_adding_the_tag": "An error occurred adding the tag",
+ "an_error_occurred_creating_the_tag": "An error occurred creating the tag",
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
"browser": "Browser",
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "Setup integrations",
"share_survey": "Share survey",
"show_all_responses_that_match": "Show all responses that match",
- "show_all_responses_where": "Show all responses where...",
"starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index b12fab10ff..3f7c18ae0f 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -200,6 +200,7 @@
"edit": "Modifier",
"email": "Email",
"ending_card": "Carte de fin",
+ "enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
+ "failed_to_load_organizations": "Échec du chargement des organisations",
+ "failed_to_load_projects": "Échec du chargement des projets",
"finish": "Terminer",
"follow_these": "Suivez ceci",
"formbricks_version": "Version de Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segments",
"select": "Sélectionner",
"select_all": "Sélectionner tout",
+ "select_filter": "Sélectionner un filtre",
"select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
@@ -886,7 +890,6 @@
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
}
},
- "projects_environments_organizations_not_found": "Projets, environnements ou organisations non trouvés",
"segments": {
"add_filter_below": "Ajouter un filtre ci-dessous",
"add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer.",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "Ligne d'adresse 1",
"address_line_2": "Ligne d'adresse 2",
+ "an_error_occurred_adding_the_tag": "Une erreur est survenue lors de l'ajout de l'étiquette",
+ "an_error_occurred_creating_the_tag": "Une erreur est survenue lors de la création de l'étiquette",
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
"browser": "Navigateur",
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "Configurer les intégrations",
"share_survey": "Partager l'enquête",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
- "show_all_responses_where": "Afficher toutes les réponses où...",
"starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index b550c38528..5f70820ab1 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -200,6 +200,7 @@
"edit": "編集",
"email": "メールアドレス",
"ending_card": "終了カード",
+ "enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開",
+ "failed_to_load_organizations": "組織の読み込みに失敗しました",
+ "failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
"finish": "完了",
"follow_these": "こちらの手順に従って",
"formbricks_version": "Formbricksバージョン",
@@ -355,6 +358,7 @@
"segments": "セグメント",
"select": "選択",
"select_all": "すべて選択",
+ "select_filter": "フィルターを選択",
"select_survey": "フォームを選択",
"select_teams": "チームを選択",
"selected": "選択済み",
@@ -886,7 +890,6 @@
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
}
},
- "projects_environments_organizations_not_found": "プロジェクト、環境、または組織が見つかりません",
"segments": {
"add_filter_below": "下にフィルターを追加",
"add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "住所1",
"address_line_2": "住所2",
+ "an_error_occurred_adding_the_tag": "タグの追加中にエラーが発生しました",
+ "an_error_occurred_creating_the_tag": "タグの作成中にエラーが発生しました",
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
"browser": "ブラウザ",
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "連携を設定",
"share_survey": "フォームを共有",
"show_all_responses_that_match": "一致するすべての回答を表示",
- "show_all_responses_where": "以下のすべての回答を表示...",
"starts": "開始",
"starts_tooltip": "フォームが開始された回数。",
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index a9aad437a5..286064a997 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -200,6 +200,7 @@
"edit": "Editar",
"email": "Email",
"ending_card": "Cartão de encerramento",
+ "enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
+ "failed_to_load_organizations": "Falha ao carregar organizações",
+ "failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Terminar",
"follow_these": "Siga esses",
"formbricks_version": "Versão do Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segmentos",
"select": "Selecionar",
"select_all": "Selecionar tudo",
+ "select_filter": "Selecionar filtro",
"select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times",
"selected": "Selecionado",
@@ -886,7 +890,6 @@
"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."
}
},
- "projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
"segments": {
"add_filter_below": "Adicionar filtro abaixo",
"add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "Endereço Linha 1",
"address_line_2": "Complemento",
+ "an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a tag",
+ "an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a tag",
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
"browser": "navegador",
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "Configurar integrações",
"share_survey": "Compartilhar pesquisa",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
- "show_all_responses_where": "Mostre todas as respostas onde...",
"starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 769a0f82e2..9b8d584a22 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -200,6 +200,7 @@
"edit": "Editar",
"email": "Email",
"ending_card": "Cartão de encerramento",
+ "enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
+ "failed_to_load_organizations": "Falha ao carregar organizações",
+ "failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Concluir",
"follow_these": "Siga estes",
"formbricks_version": "Versão do Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segmentos",
"select": "Selecionar",
"select_all": "Selecionar tudo",
+ "select_filter": "Selecionar filtro",
"select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
@@ -886,7 +890,6 @@
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
}
},
- "projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
"segments": {
"add_filter_below": "Adicionar filtro abaixo",
"add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "Endereço Linha 1",
"address_line_2": "Endereço Linha 2",
+ "an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a etiqueta",
+ "an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a etiqueta",
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
"browser": "Navegador",
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "Configurar integrações",
"share_survey": "Partilhar inquérito",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
- "show_all_responses_where": "Mostrar todas as respostas onde...",
"starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index d1d153885b..b1dc2bf7a3 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -200,6 +200,7 @@
"edit": "Editare",
"email": "Email",
"ending_card": "Cardul de finalizare",
+ "enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
"error_rate_limit_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile",
+ "failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
+ "failed_to_load_projects": "Nu s-a reușit încărcarea proiectelor",
"finish": "Finalizează",
"follow_these": "Urmați acestea",
"formbricks_version": "Versiunea Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segment",
"select": "Selectați",
"select_all": "Selectați toate",
+ "select_filter": "Selectați filtrul",
"select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele",
"selected": "Selectat",
@@ -886,7 +890,6 @@
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
}
},
- "projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite",
"segments": {
"add_filter_below": "Adăugați un filtru mai jos",
"add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "Adresă Linie 1",
"address_line_2": "Adresă Linie 2",
+ "an_error_occurred_adding_the_tag": "A apărut o eroare la adăugarea etichetei",
+ "an_error_occurred_creating_the_tag": "A apărut o eroare la crearea etichetei",
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
"browser": "Browser",
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "Configurare integrare",
"share_survey": "Distribuie chestionarul",
"show_all_responses_that_match": "Afișează toate răspunsurile care corespund",
- "show_all_responses_where": "Afișează toate răspunsurile unde...",
"starts": "Începuturi",
"starts_tooltip": "Număr de ori când sondajul a fost început.",
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index ceea834414..3690d05cad 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -200,6 +200,7 @@
"edit": "编辑",
"email": "邮箱",
"ending_card": "结尾卡片",
+ "enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
"error_rate_limit_title": "速率 限制 超过",
"expand_rows": "展开 行",
+ "failed_to_load_organizations": "加载组织失败",
+ "failed_to_load_projects": "加载项目失败",
"finish": "完成",
"follow_these": "遵循 这些",
"formbricks_version": "Formbricks 版本",
@@ -355,6 +358,7 @@
"segments": "细分",
"select": "选择",
"select_all": "选择 全部",
+ "select_filter": "选择过滤器",
"select_survey": "选择 调查",
"select_teams": "选择 团队",
"selected": "已选择",
@@ -886,7 +890,6 @@
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
}
},
- "projects_environments_organizations_not_found": "项目 、 环境 或 组织 未 找到",
"segments": {
"add_filter_below": "在下方添加 过滤器",
"add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "地址 第1行",
"address_line_2": "地址 第2行",
+ "an_error_occurred_adding_the_tag": "添加标签时发生错误",
+ "an_error_occurred_creating_the_tag": "创建标签时发生错误",
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
"browser": "浏览器",
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "设置 集成",
"share_survey": "分享 问卷调查",
"show_all_responses_that_match": "显示所有匹配的响应",
- "show_all_responses_where": "显示所有的响应,条件为...",
"starts": "开始",
"starts_tooltip": "调查 被 开始 的 次数",
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 66d56fe62b..eae646e923 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -200,6 +200,7 @@
"edit": "編輯",
"email": "電子郵件",
"ending_card": "結尾卡片",
+ "enter_url": "輸入 URL",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
+ "failed_to_load_organizations": "無法載入組織",
+ "failed_to_load_projects": "無法載入專案",
"finish": "完成",
"follow_these": "按照這些步驟",
"formbricks_version": "Formbricks 版本",
@@ -355,6 +358,7 @@
"segments": "區隔",
"select": "選擇",
"select_all": "全選",
+ "select_filter": "選擇篩選器",
"select_survey": "選擇問卷",
"select_teams": "選擇 團隊",
"selected": "已選取",
@@ -886,7 +890,6 @@
"team_settings_description": "查看哪些團隊可以存取此專案。"
}
},
- "projects_environments_organizations_not_found": "找不到專案、環境或組織",
"segments": {
"add_filter_below": "在下方新增篩選器",
"add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用",
@@ -1661,6 +1664,8 @@
"responses": {
"address_line_1": "地址 1",
"address_line_2": "地址 2",
+ "an_error_occurred_adding_the_tag": "新增標籤時發生錯誤",
+ "an_error_occurred_creating_the_tag": "建立標籤時發生錯誤",
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
"browser": "瀏覽器",
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
@@ -1877,7 +1882,6 @@
"setup_integrations": "設定整合",
"share_survey": "分享問卷",
"show_all_responses_that_match": "顯示所有相符的回應",
- "show_all_responses_where": "顯示所有回應,其中...",
"starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx
index ceb8ddfc81..1522db4646 100644
--- a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx
+++ b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx
@@ -1,10 +1,11 @@
"use client";
-import { AlertCircleIcon, SettingsIcon } from "lucide-react";
+import { SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
+import { logger } from "@formbricks/logger";
import { TTag } from "@formbricks/types/tags";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
@@ -39,14 +40,19 @@ export const ResponseTagsWrapper: React.FC
= ({
const [open, setOpen] = React.useState(false);
const [tagsState, setTagsState] = useState(tags);
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
+ const [isLoadingTagOperation, setIsLoadingTagOperation] = useState(false);
const onDelete = async (tagId: string) => {
- try {
- await deleteTagOnResponseAction({ responseId, tagId });
+ setIsLoadingTagOperation(true);
+ const deleteTagResponse = await deleteTagOnResponseAction({ responseId, tagId });
+ if (deleteTagResponse?.data) {
updateFetchedResponses();
- } catch (e) {
+ } else {
+ const errorMessage = getFormattedErrorMessage(deleteTagResponse);
+ logger.error({ errorMessage }, "Error deleting tag");
toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag"));
}
+ setIsLoadingTagOperation(false);
};
useEffect(() => {
@@ -60,72 +66,70 @@ export const ResponseTagsWrapper: React.FC = ({
}, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => {
- setOpen(false);
+ setIsLoadingTagOperation(true);
+ const newTagResponse = await createTagAction({ environmentId, tagName });
- const createTagResponse = await createTagAction({
- environmentId,
- tagName: tagName?.trim() ?? "",
- });
+ if (!newTagResponse?.data) {
+ toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
+ return;
+ }
- if (createTagResponse?.data?.ok) {
- const tag = createTagResponse.data.data;
- setTagsState((prevTags) => [
- ...prevTags,
- {
- tagId: tag.id,
- tagName: tag.name,
- },
- ]);
-
- const createTagToResponseActionResponse = await createTagToResponseAction({
- responseId,
- tagId: tag.id,
- });
-
- if (createTagToResponseActionResponse?.data) {
- updateFetchedResponses();
- setSearchValue("");
+ if (!newTagResponse.data.ok) {
+ const errorMessage = newTagResponse.data.error;
+ if (errorMessage?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
+ toast.error(t("environments.surveys.responses.tag_already_exists"), {
+ duration: 2000,
+ icon: ,
+ });
} else {
- const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
- toast.error(errorMessage);
+ toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
}
-
return;
}
- if (
- createTagResponse?.data?.ok === false &&
- createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
- ) {
- toast.error(t("environments.surveys.responses.tag_already_exists"), {
- duration: 2000,
- icon: ,
- });
-
+ const newTag = newTagResponse.data.data;
+ const createTagToResponseResponse = await createTagToResponseAction({ responseId, tagId: newTag.id });
+ if (createTagToResponseResponse?.data) {
+ setTagsState((prevTags) => [...prevTags, { tagId: newTag.id, tagName: newTag.name }]);
+ setTagIdToHighlight(newTag.id);
+ updateFetchedResponses();
setSearchValue("");
- return;
+ setOpen(false);
+ } else {
+ const errorMessage = getFormattedErrorMessage(createTagToResponseResponse);
+ logger.error({ errorMessage });
+ toast.error(errorMessage);
}
+ setIsLoadingTagOperation(false);
+ };
- const errorMessage = getFormattedErrorMessage(createTagResponse);
- toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
- duration: 2000,
- });
- setSearchValue("");
+ const handleAddTag = async (tagId: string) => {
+ setIsLoadingTagOperation(true);
+ setTagsState((prevTags) => [
+ ...prevTags,
+ {
+ tagId,
+ tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
+ },
+ ]);
+
+ try {
+ await createTagToResponseAction({ responseId, tagId });
+ updateFetchedResponses();
+ setSearchValue("");
+ setOpen(false);
+ } catch (error) {
+ toast.error(t("environments.surveys.responses.an_error_occurred_adding_the_tag"));
+ console.error("Error adding tag:", error);
+ // Revert the tag if the action failed
+ setTagsState((prevTags) => prevTags.filter((tag) => tag.tagId !== tagId));
+ } finally {
+ setIsLoadingTagOperation(false);
+ }
};
return (
-
- {!isReadOnly && (
-
- )}
+
{tagsState?.map((tag) => (
= ({
tags={tagsState}
setTagsState={setTagsState}
highlight={tagIdToHighlight === tag.tagId}
- allowDelete={!isReadOnly}
+ allowDelete={!isReadOnly && !isLoadingTagOperation}
/>
))}
{!isReadOnly && (
({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={handleCreateTag}
- addTag={(tagId) => {
- setTagsState((prevTags) => [
- ...prevTags,
- {
- tagId,
- tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
- },
- ]);
-
- createTagToResponseAction({ responseId, tagId }).then(() => {
- updateFetchedResponses();
- setSearchValue("");
- setOpen(false);
- });
- }}
+ addTag={handleAddTag}
/>
)}
+
+ {!isReadOnly && (
+
+ )}
);
};
diff --git a/apps/web/modules/analysis/components/SingleResponseCard/index.tsx b/apps/web/modules/analysis/components/SingleResponseCard/index.tsx
index 07ddf25c0b..21c441490a 100644
--- a/apps/web/modules/analysis/components/SingleResponseCard/index.tsx
+++ b/apps/web/modules/analysis/components/SingleResponseCard/index.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
@@ -42,46 +41,58 @@ export const SingleResponseCard = ({
setSelectedResponseId,
locale,
}: SingleResponseCardProps) => {
- const hasQuotas = (response.quotas && response.quotas.length > 0) ?? false;
+ const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const { t } = useTranslation();
const environmentId = survey.environmentId;
- const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
- let skippedQuestions: string[][] = [];
- let temp: string[] = [];
+ const skippedQuestions: string[][] = useMemo(() => {
+ const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
+ if (temp.length > 0) {
+ if (shouldReverse) temp.reverse();
+ result.push([...temp]);
+ temp.length = 0;
+ }
+ };
- if (response.finished) {
- survey.questions.forEach((question) => {
- if (!isValidValue(response.data[question.id])) {
- temp.push(question.id);
- } else if (temp.length > 0) {
- skippedQuestions.push([...temp]);
- temp = [];
+ const processFinishedResponse = () => {
+ const result: string[][] = [];
+ let temp: string[] = [];
+
+ for (const question of survey.questions) {
+ if (isValidValue(response.data[question.id])) {
+ flushTemp(temp, result);
+ } else {
+ temp.push(question.id);
+ }
}
- });
- } else {
- for (let index = survey.questions.length - 1; index >= 0; index--) {
- const question = survey.questions[index];
- if (
- !response.data[question.id] &&
- (skippedQuestions.length === 0 ||
- (skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
- ) {
- temp.push(question.id);
- } else if (temp.length > 0) {
- temp.reverse();
- skippedQuestions.push([...temp]);
- temp = [];
+ flushTemp(temp, result);
+ return result;
+ };
+
+ const processUnfinishedResponse = () => {
+ const result: string[][] = [];
+ let temp: string[] = [];
+
+ for (let index = survey.questions.length - 1; index >= 0; index--) {
+ const question = survey.questions[index];
+ const hasNoData = !response.data[question.id];
+ const shouldSkip = hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
+
+ if (shouldSkip) {
+ temp.push(question.id);
+ } else {
+ flushTemp(temp, result, true);
+ }
}
- }
- }
- // Handle the case where the last entries are empty
- if (temp.length > 0) {
- skippedQuestions.push(temp);
- }
+ flushTemp(temp, result);
+ return result;
+ };
+
+ return response.finished ? processFinishedResponse() : processUnfinishedResponse();
+ }, [response.id, response.finished, response.data, survey.questions]);
const handleDeleteResponse = async () => {
setIsDeleting(true);
@@ -91,7 +102,6 @@ export const SingleResponseCard = ({
}
await deleteResponseAction({ responseId: response.id, decrementQuotas });
updateResponseList?.([response.id]);
- router.refresh();
if (setSelectedResponseId) setSelectedResponseId(null);
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
setDeleteDialogOpen(false);
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts
index 67e0f2d1e6..35d23eb7c3 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
-import { ZResponseInput } from "@formbricks/types/responses";
+import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
@@ -52,7 +52,8 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
export const updateResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "updateResponse",
summary: "Update a response",
- description: "Updates a response in the database.",
+ description:
+ "Updates a response in the database. This will trigger the response pipeline, including webhooks, integrations, follow-up emails (if the response is marked as finished), and other configured actions.",
tags: ["Management API - Responses"],
requestParams: {
path: z.object({
@@ -61,10 +62,10 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
},
requestBody: {
required: true,
- description: "The response to update",
+ description: "The response fields to update",
content: {
"application/json": {
- schema: ZResponseInput,
+ schema: ZResponseUpdateInput,
},
},
},
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts
index 6f981e5292..ebf6df75a0 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
+import { TResponse } from "@formbricks/types/responses";
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
@@ -32,6 +33,58 @@ export const getResponse = reactCache(async (responseId: string) => {
}
});
+export const getResponseForPipeline = async (
+ responseId: string
+): Promise
> => {
+ try {
+ const responsePrisma = await prisma.response.findUnique({
+ where: {
+ id: responseId,
+ },
+ include: {
+ contact: {
+ select: {
+ id: true,
+ },
+ },
+ tags: {
+ select: {
+ tag: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ name: true,
+ environmentId: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!responsePrisma) {
+ return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
+ }
+
+ return ok({
+ ...responsePrisma,
+ contact: responsePrisma.contact
+ ? {
+ id: responsePrisma.contact.id,
+ userId: responsePrisma.contactAttributes?.userId,
+ }
+ : null,
+ tags: responsePrisma.tags.map((t) => t.tag),
+ });
+ } catch (error) {
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "response", issue: error.message }],
+ });
+ }
+};
+
export const deleteResponse = async (responseId: string): Promise> => {
try {
const deletedResponse = await prisma.response.delete({
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts
index 82ad68306d..13b704181d 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts
@@ -7,7 +7,13 @@ import { ok, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuota } from "@formbricks/types/quota";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { deleteDisplay } from "../display";
-import { deleteResponse, getResponse, updateResponse, updateResponseWithQuotaEvaluation } from "../response";
+import {
+ deleteResponse,
+ getResponse,
+ getResponseForPipeline,
+ updateResponse,
+ updateResponseWithQuotaEvaluation,
+} from "../response";
import { getSurveyQuestions } from "../survey";
import { findAndDeleteUploadedFilesInResponse } from "../utils";
@@ -106,6 +112,177 @@ describe("Response Lib", () => {
});
});
+ describe("getResponseForPipeline", () => {
+ test("return the response with contact and tags when found", async () => {
+ const mockPrismaResponse = {
+ id: responseId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
+ displayId: "jowdit1qrf04t97jcc0io9di",
+ finished: true,
+ data: { question1: "answer1" },
+ meta: {},
+ ttc: {},
+ variables: {},
+ contactAttributes: { userId: "user123" },
+ singleUseId: null,
+ language: "en",
+ endingId: null,
+ contact: {
+ id: "olwablfltg9eszoh0nz83w02",
+ },
+ tags: [
+ {
+ tag: {
+ id: "tag123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "important",
+ environmentId: "env123",
+ },
+ },
+ ],
+ };
+
+ vi.mocked(prisma.response.findUnique).mockResolvedValue(mockPrismaResponse as any);
+
+ const result = await getResponseForPipeline(responseId);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toEqual({
+ ...mockPrismaResponse,
+ contact: {
+ id: "olwablfltg9eszoh0nz83w02",
+ userId: "user123",
+ },
+ tags: [
+ {
+ id: "tag123",
+ createdAt: mockPrismaResponse.tags[0].tag.createdAt,
+ updatedAt: mockPrismaResponse.tags[0].tag.updatedAt,
+ name: "important",
+ environmentId: "env123",
+ },
+ ],
+ });
+ }
+ expect(prisma.response.findUnique).toHaveBeenCalledWith({
+ where: { id: responseId },
+ include: {
+ contact: {
+ select: {
+ id: true,
+ },
+ },
+ tags: {
+ select: {
+ tag: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ name: true,
+ environmentId: true,
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+
+ test("return the response with null contact when contact does not exist", async () => {
+ const mockPrismaResponseWithoutContact = {
+ id: responseId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
+ displayId: "jowdit1qrf04t97jcc0io9di",
+ finished: true,
+ data: { question1: "answer1" },
+ meta: {},
+ ttc: {},
+ variables: {},
+ contactAttributes: null,
+ singleUseId: null,
+ language: "en",
+ endingId: null,
+ contact: null,
+ tags: [],
+ };
+
+ vi.mocked(prisma.response.findUnique).mockResolvedValue(mockPrismaResponseWithoutContact as any);
+
+ const result = await getResponseForPipeline(responseId);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.contact).toBeNull();
+ expect(result.data.tags).toEqual([]);
+ }
+ });
+
+ test("return a not_found error when the response is missing", async () => {
+ vi.mocked(prisma.response.findUnique).mockResolvedValue(null);
+
+ const result = await getResponseForPipeline(responseId);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toEqual({
+ type: "not_found",
+ details: [{ field: "response", issue: "not found" }],
+ });
+ }
+ });
+
+ test("return an internal_server_error when prisma throws an error", async () => {
+ vi.mocked(prisma.response.findUnique).mockRejectedValue(new Error("DB error"));
+
+ const result = await getResponseForPipeline(responseId);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toEqual({
+ type: "internal_server_error",
+ details: [{ field: "response", issue: "DB error" }],
+ });
+ }
+ });
+
+ test("handle response with contact but no userId in contactAttributes", async () => {
+ const mockPrismaResponse = {
+ id: responseId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
+ displayId: null,
+ finished: false,
+ data: {},
+ meta: {},
+ ttc: {},
+ variables: {},
+ contactAttributes: {},
+ singleUseId: null,
+ language: "en",
+ endingId: null,
+ contact: {
+ id: "contact-id",
+ },
+ tags: [],
+ };
+
+ vi.mocked(prisma.response.findUnique).mockResolvedValue(mockPrismaResponse as any);
+
+ const result = await getResponseForPipeline(responseId);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.contact).toEqual({
+ id: "contact-id",
+ userId: undefined,
+ });
+ }
+ });
+ });
+
describe("deleteResponse", () => {
test("delete the response, delete the display and remove uploaded files", async () => {
vi.mocked(prisma.response.delete).mockResolvedValue(response);
diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts
index 74c65fbe17..1ad53d7447 100644
--- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts
+++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { sendToPipeline } from "@/app/lib/pipelines";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
@@ -7,6 +8,7 @@ import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,
getResponse,
+ getResponseForPipeline,
updateResponseWithQuotaEvaluation,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
@@ -124,7 +126,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
{
type: "bad_request",
- details: [{ field: !body ? "body" : "params", issue: "missing" }],
+ details: [{ field: body ? "params" : "body", issue: "missing" }],
},
auditLog
);
@@ -196,6 +198,26 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
+ // Fetch updated response with relations for pipeline
+ const updatedResponseForPipeline = await getResponseForPipeline(params.responseId);
+ if (updatedResponseForPipeline.ok) {
+ sendToPipeline({
+ event: "responseUpdated",
+ environmentId: environmentIdResult.data,
+ surveyId: existingResponse.data.surveyId,
+ response: updatedResponseForPipeline.data,
+ });
+
+ if (response.data.finished) {
+ sendToPipeline({
+ event: "responseFinished",
+ environmentId: environmentIdResult.data,
+ surveyId: existingResponse.data.surveyId,
+ response: updatedResponseForPipeline.data,
+ });
+ }
+ }
+
if (auditLog) {
auditLog.oldObject = existingResponse.data;
auditLog.newObject = response.data;
diff --git a/apps/web/modules/api/v2/management/responses/lib/contact.ts b/apps/web/modules/api/v2/management/responses/lib/contact.ts
new file mode 100644
index 0000000000..baf9cdaf29
--- /dev/null
+++ b/apps/web/modules/api/v2/management/responses/lib/contact.ts
@@ -0,0 +1,62 @@
+import "server-only";
+import { prisma } from "@formbricks/database";
+import { TContactAttributes } from "@formbricks/types/contact-attribute";
+import { Result, err, ok } from "@formbricks/types/error-handlers";
+import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
+
+export const getContactByUserId = async (
+ environmentId: string,
+ userId: string
+): Promise<
+ Result<
+ {
+ id: string;
+ attributes: TContactAttributes;
+ } | null,
+ ApiErrorResponseV2
+ >
+> => {
+ try {
+ const contact = await prisma.contact.findFirst({
+ where: {
+ attributes: {
+ some: {
+ attributeKey: {
+ key: "userId",
+ environmentId,
+ },
+ value: userId,
+ },
+ },
+ },
+ select: {
+ id: true,
+ attributes: {
+ select: {
+ attributeKey: { select: { key: true } },
+ value: true,
+ },
+ },
+ },
+ });
+
+ if (!contact) {
+ return ok(null);
+ }
+
+ const contactAttributes = contact.attributes.reduce((acc, attr) => {
+ acc[attr.attributeKey.key] = attr.value;
+ return acc;
+ }, {}) as TContactAttributes;
+
+ return ok({
+ id: contact.id,
+ attributes: contactAttributes,
+ });
+ } catch (error) {
+ return err({
+ type: "internal_server_error",
+ details: [{ field: "contact", issue: error.message }],
+ });
+ }
+};
diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts
index 5f81e99642..d642cefa27 100644
--- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts
@@ -32,7 +32,8 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
export const createResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "createResponse",
summary: "Create a response",
- description: "Creates a response in the database.",
+ description:
+ "Creates a response in the database. This will trigger the response pipeline, including webhooks, integrations, follow-up emails, and other configured actions.",
tags: ["Management API - Responses"],
requestBody: {
required: true,
diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts
index f8b242c98f..09de56b895 100644
--- a/apps/web/modules/api/v2/management/responses/lib/response.ts
+++ b/apps/web/modules/api/v2/management/responses/lib/response.ts
@@ -2,11 +2,13 @@ import "server-only";
import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
+import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
+import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
import {
getMonthlyOrganizationResponseCount,
getOrganizationBilling,
@@ -54,6 +56,7 @@ export const createResponse = async (
const {
surveyId,
displayId,
+ userId,
finished,
data,
language,
@@ -67,6 +70,17 @@ export const createResponse = async (
} = responseInput;
try {
+ let contact: { id: string; attributes: TContactAttributes } | null = null;
+
+ // If userId is provided, look up the contact by userId
+ if (userId) {
+ const contactResult = await getContactByUserId(environmentId, userId);
+ if (!contactResult.ok) {
+ return err(contactResult.error);
+ }
+ contact = contactResult.data;
+ }
+
let ttc = {};
if (initialTtc) {
if (finished) {
@@ -83,6 +97,14 @@ export const createResponse = async (
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
+ ...(contact?.id && {
+ contact: {
+ connect: {
+ id: contact.id,
+ },
+ },
+ contactAttributes: contact.attributes,
+ }),
finished,
data,
language,
diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/contact.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/contact.test.ts
new file mode 100644
index 0000000000..1dc2378462
--- /dev/null
+++ b/apps/web/modules/api/v2/management/responses/lib/tests/contact.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { TContactAttributes } from "@formbricks/types/contact-attribute";
+import { getContactByUserId } from "../contact";
+
+// Mock prisma
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ contact: {
+ findFirst: vi.fn(),
+ },
+ },
+}));
+
+const environmentId = "test-env-id";
+const userId = "test-user-id";
+const contactId = "test-contact-id";
+
+const mockContactDbData = {
+ id: contactId,
+ environmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ attributes: [
+ { attributeKey: { key: "userId" }, value: userId },
+ { attributeKey: { key: "email" }, value: "test@example.com" },
+ { attributeKey: { key: "plan" }, value: "premium" },
+ ],
+};
+
+const expectedContactAttributes: TContactAttributes = {
+ userId: userId,
+ email: "test@example.com",
+ plan: "premium",
+};
+
+describe("getContactByUserId", () => {
+ test("should return ok result with contact and attributes when found", async () => {
+ vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
+
+ const result = await getContactByUserId(environmentId, userId);
+
+ expect(prisma.contact.findFirst).toHaveBeenCalledWith({
+ where: {
+ attributes: {
+ some: {
+ attributeKey: {
+ key: "userId",
+ environmentId,
+ },
+ value: userId,
+ },
+ },
+ },
+ select: {
+ id: true,
+ attributes: {
+ select: {
+ attributeKey: { select: { key: true } },
+ value: true,
+ },
+ },
+ },
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toEqual({
+ id: contactId,
+ attributes: expectedContactAttributes,
+ });
+ }
+ });
+
+ test("should return ok result with null when contact is not found", async () => {
+ vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
+
+ const result = await getContactByUserId(environmentId, userId);
+
+ expect(prisma.contact.findFirst).toHaveBeenCalledWith({
+ where: {
+ attributes: {
+ some: {
+ attributeKey: {
+ key: "userId",
+ environmentId,
+ },
+ value: userId,
+ },
+ },
+ },
+ select: {
+ id: true,
+ attributes: {
+ select: {
+ attributeKey: { select: { key: true } },
+ value: true,
+ },
+ },
+ },
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toBeNull();
+ }
+ });
+
+ test("should return error result when database throws an error", async () => {
+ const errorMessage = "Database connection failed";
+ vi.mocked(prisma.contact.findFirst).mockRejectedValue(new Error(errorMessage));
+
+ const result = await getContactByUserId(environmentId, userId);
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toEqual({
+ type: "internal_server_error",
+ details: [{ field: "contact", issue: errorMessage }],
+ });
+ }
+ });
+
+ test("should correctly transform multiple attributes", async () => {
+ const mockContactWithManyAttributes = {
+ id: contactId,
+ environmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ attributes: [
+ { attributeKey: { key: "userId" }, value: "user123" },
+ { attributeKey: { key: "email" }, value: "multi@example.com" },
+ { attributeKey: { key: "firstName" }, value: "John" },
+ { attributeKey: { key: "lastName" }, value: "Doe" },
+ { attributeKey: { key: "company" }, value: "Acme Corp" },
+ ],
+ };
+
+ vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactWithManyAttributes);
+
+ const result = await getContactByUserId(environmentId, userId);
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data?.attributes).toEqual({
+ userId: "user123",
+ email: "multi@example.com",
+ firstName: "John",
+ lastName: "Doe",
+ company: "Acme Corp",
+ });
+ }
+ });
+
+ test("should handle contact with empty attributes array", async () => {
+ const mockContactWithNoAttributes = {
+ id: contactId,
+ environmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ attributes: [],
+ };
+
+ vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactWithNoAttributes);
+
+ const result = await getContactByUserId(environmentId, userId);
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toEqual({
+ id: contactId,
+ attributes: {},
+ });
+ }
+ });
+});
diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts
index 25af9b3d26..54ce7dc68f 100644
--- a/apps/web/modules/api/v2/management/responses/route.ts
+++ b/apps/web/modules/api/v2/management/responses/route.ts
@@ -1,10 +1,12 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
+import { sendToPipeline } from "@/app/lib/pipelines";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
+import { getResponseForPipeline } from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -131,6 +133,26 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error, auditLog);
}
+ // Fetch created response with relations for pipeline
+ const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
+ if (createdResponseForPipeline.ok) {
+ sendToPipeline({
+ event: "responseCreated",
+ environmentId: environmentId,
+ surveyId: body.surveyId,
+ response: createdResponseForPipeline.data,
+ });
+
+ if (createResponseResult.data.finished) {
+ sendToPipeline({
+ event: "responseFinished",
+ environmentId: environmentId,
+ surveyId: body.surveyId,
+ response: createdResponseForPipeline.data,
+ });
+ }
+ }
+
if (auditLog) {
auditLog.targetId = createResponseResult.data.id;
auditLog.newObject = createResponseResult.data;
diff --git a/apps/web/modules/api/v2/management/responses/types/responses.ts b/apps/web/modules/api/v2/management/responses/types/responses.ts
index 80897e11c0..e59808b4dc 100644
--- a/apps/web/modules/api/v2/management/responses/types/responses.ts
+++ b/apps/web/modules/api/v2/management/responses/types/responses.ts
@@ -32,16 +32,20 @@ export const ZResponseInput = ZResponse.pick({
variables: true,
ttc: true,
meta: true,
-}).partial({
- displayId: true,
- singleUseId: true,
- endingId: true,
- language: true,
- variables: true,
- ttc: true,
- meta: true,
- createdAt: true,
- updatedAt: true,
-});
+})
+ .partial({
+ displayId: true,
+ singleUseId: true,
+ endingId: true,
+ language: true,
+ variables: true,
+ ttc: true,
+ meta: true,
+ createdAt: true,
+ updatedAt: true,
+ })
+ .extend({
+ userId: z.string().optional(),
+ });
export type TResponseInput = z.infer;
diff --git a/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx b/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx
index a176beabc7..236462b2bc 100644
--- a/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx
+++ b/apps/web/modules/ee/contacts/components/upload-contacts-attribute-combobox.tsx
@@ -92,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
}}
/>
-
+
{keys.map((tag) => {
return (
diff --git a/apps/web/modules/environments/lib/utils.test.ts b/apps/web/modules/environments/lib/utils.test.ts
index 6d18badbfd..36f0b10d1b 100644
--- a/apps/web/modules/environments/lib/utils.test.ts
+++ b/apps/web/modules/environments/lib/utils.test.ts
@@ -2,6 +2,7 @@
// Pull in the mocked implementations to configure them in tests
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
@@ -12,12 +13,24 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
+import {
+ getMonthlyActiveOrganizationPeopleCount,
+ getMonthlyOrganizationResponseCount,
+ getOrganizationByEnvironmentId,
+} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
+import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
+import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
-import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
+// Pull in the mocked implementations to configure them in tests
+import {
+ environmentIdLayoutChecks,
+ getEnvironmentAuth,
+ getEnvironmentLayoutData,
+ getEnvironmentWithRelations,
+} from "./utils";
// Mock all external dependencies
vi.mock("@/lingodotdev/server", () => ({
@@ -58,6 +71,8 @@ vi.mock("@/lib/membership/utils", () => ({
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
+ getMonthlyActiveOrganizationPeopleCount: vi.fn(),
+ getMonthlyOrganizationResponseCount: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
@@ -68,12 +83,36 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
+vi.mock("@/modules/ee/license-check/lib/license", () => ({
+ getEnterpriseLicense: vi.fn(),
+}));
+
+vi.mock("@/modules/ee/license-check/lib/utils", () => ({
+ getAccessControlPermission: vi.fn(),
+}));
+
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ environment: {
+ findUnique: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+}));
+
vi.mock("@formbricks/types/errors", () => ({
AuthorizationError: class AuthorizationError extends Error {},
+ DatabaseError: class DatabaseError extends Error {},
}));
describe("utils.ts", () => {
beforeEach(() => {
+ // Clear all mocks before each test
+ vi.clearAllMocks();
+
// Provide default mocks for successful scenario
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
@@ -96,6 +135,16 @@ describe("utils.ts", () => {
});
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser);
+ vi.mocked(getEnterpriseLicense).mockResolvedValue({
+ active: true,
+ features: { isMultiOrgEnabled: false },
+ lastChecked: new Date(),
+ isPendingDowngrade: false,
+ fallbackLevel: "none",
+ } as any);
+ vi.mocked(getAccessControlPermission).mockResolvedValue(true);
+ vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(0);
+ vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(0);
});
describe("getEnvironmentAuth", () => {
@@ -170,4 +219,434 @@ describe("utils.ts", () => {
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
});
});
+
+ describe("getEnvironmentWithRelations", () => {
+ const mockPrismaData = {
+ id: "env123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ type: "production" as const,
+ projectId: "proj123",
+ appSetupCompleted: true,
+ project: {
+ id: "proj123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ name: "Test Project",
+ organizationId: "org123",
+ languages: ["en"],
+ recontactDays: 7,
+ linkSurveyBranding: true,
+ inAppSurveyBranding: true,
+ config: {},
+ placement: "bottomRight" as const,
+ clickOutsideClose: true,
+ darkOverlay: false,
+ styling: {},
+ logo: null,
+ environments: [
+ {
+ id: "env123",
+ type: "production" as const,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ projectId: "proj123",
+ appSetupCompleted: true,
+ },
+ {
+ id: "env456",
+ type: "development" as const,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ projectId: "proj123",
+ appSetupCompleted: false,
+ },
+ ],
+ organization: {
+ id: "org123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ name: "Test Organization",
+ billing: { plan: "free" },
+ isAIEnabled: false,
+ whitelabel: false,
+ memberships: [
+ {
+ userId: "user123",
+ organizationId: "org123",
+ accepted: true,
+ role: "owner" as const,
+ },
+ ],
+ },
+ },
+ };
+
+ beforeEach(() => {
+ vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockPrismaData as any);
+ });
+
+ test("returns combined environment, project, organization, and membership data", async () => {
+ const result = await getEnvironmentWithRelations("env123", "user123");
+
+ expect(result).toBeDefined();
+ expect(result!.environment.id).toBe("env123");
+ expect(result!.environment.type).toBe("production");
+ expect(result!.project.id).toBe("proj123");
+ expect(result!.project.name).toBe("Test Project");
+ expect(result!.organization.id).toBe("org123");
+ expect(result!.organization.name).toBe("Test Organization");
+ expect(result!.environments).toHaveLength(2);
+ expect(result!.membership).toEqual({
+ userId: "user123",
+ organizationId: "org123",
+ accepted: true,
+ role: "owner",
+ });
+ });
+
+ test("fetches only current user's membership using database-level filtering", async () => {
+ await getEnvironmentWithRelations("env123", "user123");
+
+ expect(prisma.environment.findUnique).toHaveBeenCalledWith({
+ where: { id: "env123" },
+ select: expect.objectContaining({
+ project: expect.objectContaining({
+ select: expect.objectContaining({
+ organization: expect.objectContaining({
+ select: expect.objectContaining({
+ memberships: expect.objectContaining({
+ where: { userId: "user123" },
+ take: 1,
+ }),
+ }),
+ }),
+ }),
+ }),
+ }),
+ });
+ });
+
+ test("returns null when environment not found", async () => {
+ vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
+
+ const result = await getEnvironmentWithRelations("env123", "user123");
+
+ expect(result).toBeNull();
+ });
+
+ test("returns null membership when user has no membership", async () => {
+ const dataWithoutMembership = {
+ ...mockPrismaData,
+ project: {
+ ...mockPrismaData.project,
+ organization: {
+ ...mockPrismaData.project.organization,
+ memberships: [], // No memberships
+ },
+ },
+ };
+ vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(dataWithoutMembership as any);
+
+ const result = await getEnvironmentWithRelations("env123", "user123");
+
+ expect(result!.membership).toBeNull();
+ });
+
+ test("throws error on database failure", async () => {
+ // Mock a database error
+ const dbError = new Error("Database connection failed");
+ vi.mocked(prisma.environment.findUnique).mockRejectedValueOnce(dbError);
+
+ // Verify function throws (specific error type depends on Prisma error detection)
+ await expect(getEnvironmentWithRelations("env123", "user123")).rejects.toThrow();
+ });
+
+ // Note: Input validation for environmentId and userId is handled by
+ // getEnvironmentLayoutData (the parent function), not here.
+ // See getEnvironmentLayoutData tests for validation coverage.
+ });
+
+ describe("getEnvironmentLayoutData", () => {
+ beforeEach(() => {
+ vi.mocked(prisma.environment.findUnique).mockResolvedValue({
+ id: "env123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ type: "production",
+ projectId: "proj123",
+ appSetupCompleted: true,
+ project: {
+ id: "proj123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ name: "Test Project",
+ organizationId: "org123",
+ languages: ["en"],
+ recontactDays: 7,
+ linkSurveyBranding: true,
+ inAppSurveyBranding: true,
+ config: {},
+ placement: "bottomRight",
+ clickOutsideClose: true,
+ darkOverlay: false,
+ styling: {},
+ logo: null,
+ environments: [
+ {
+ id: "env123",
+ type: "production",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ projectId: "proj123",
+ appSetupCompleted: true,
+ },
+ ],
+ organization: {
+ id: "org123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-02"),
+ name: "Test Organization",
+ billing: { plan: "free", limits: {} },
+ isAIEnabled: false,
+ whitelabel: false,
+ memberships: [
+ {
+ userId: "user123",
+ organizationId: "org123",
+ accepted: true,
+ role: "owner",
+ },
+ ],
+ },
+ },
+ } as any);
+ });
+
+ test("returns complete layout data on success", async () => {
+ const result = await getEnvironmentLayoutData("env123", "user123");
+
+ expect(result).toBeDefined();
+ expect(result.session).toBeDefined();
+ expect(result.user).toBeDefined();
+ expect(result.environment).toBeDefined();
+ expect(result.project).toBeDefined();
+ expect(result.organization).toBeDefined();
+ expect(result.environments).toBeDefined();
+ expect(result.membership).toBeDefined();
+ expect(result.isAccessControlAllowed).toBeDefined();
+ expect(result.projectPermission).toBeDefined();
+ expect(result.license).toBeDefined();
+ expect(result.peopleCount).toBe(0);
+ expect(result.responseCount).toBe(0);
+ });
+
+ test("validates environmentId input", async () => {
+ await expect(getEnvironmentLayoutData("", "user123")).rejects.toThrow();
+ });
+
+ test("validates userId input", async () => {
+ await expect(getEnvironmentLayoutData("env123", "")).rejects.toThrow();
+ });
+
+ test("throws error if session not found", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce(null);
+
+ await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.session_not_found");
+ });
+
+ test("throws error if userId doesn't match session", async () => {
+ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "different-user" } } as any);
+
+ await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("User ID mismatch");
+ });
+
+ test("throws error if user not found", async () => {
+ vi.mocked(getUser).mockResolvedValueOnce(null);
+
+ await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.user_not_found");
+ });
+
+ test("throws error if environment data not found", async () => {
+ vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
+
+ await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
+ "common.environment_not_found"
+ );
+ });
+
+ test("throws AuthorizationError if user has no environment access", async () => {
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
+
+ await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthorizationError);
+ });
+
+ test("throws error if membership not found", async () => {
+ vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
+ id: "env123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "proj123",
+ appSetupCompleted: true,
+ project: {
+ id: "proj123",
+ name: "Test Project",
+ organizationId: "org123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ languages: ["en"],
+ recontactDays: 7,
+ linkSurveyBranding: true,
+ inAppSurveyBranding: true,
+ config: {},
+ placement: "bottomRight",
+ clickOutsideClose: true,
+ darkOverlay: false,
+ styling: {},
+ logo: null,
+ environments: [],
+ organization: {
+ id: "org123",
+ name: "Test Organization",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: { plan: "free", limits: {} },
+ isAIEnabled: false,
+ whitelabel: false,
+ memberships: [], // No membership
+ },
+ },
+ } as any);
+
+ await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
+ "common.membership_not_found"
+ );
+ });
+
+ test("fetches user before auth check, then environment data after authorization", async () => {
+ await getEnvironmentLayoutData("env123", "user123");
+
+ // User is fetched first (needed for auth check)
+ expect(getUser).toHaveBeenCalledWith("user123");
+ // Environment data is fetched after authorization passes
+ expect(prisma.environment.findUnique).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "env123" },
+ })
+ );
+ });
+
+ test("fetches permissions and license data in parallel", async () => {
+ await getEnvironmentLayoutData("env123", "user123");
+
+ expect(getAccessControlPermission).toHaveBeenCalled();
+ expect(getProjectPermissionByUserId).toHaveBeenCalledWith("user123", "proj123");
+ expect(getEnterpriseLicense).toHaveBeenCalled();
+ });
+
+ test("fetches cloud metrics when IS_FORMBRICKS_CLOUD is true", async () => {
+ // Mock IS_FORMBRICKS_CLOUD to be true
+ const constantsMock = await import("@/lib/constants");
+ vi.mocked(constantsMock).IS_FORMBRICKS_CLOUD = true;
+
+ await getEnvironmentLayoutData("env123", "user123");
+
+ expect(getMonthlyActiveOrganizationPeopleCount).toHaveBeenCalledWith("org123");
+ expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith("org123");
+ });
+
+ test("caches results per environmentId and userId", async () => {
+ // Call twice with same parameters
+ await getEnvironmentLayoutData("env123", "user123");
+ await getEnvironmentLayoutData("env123", "user123");
+
+ // Due to React.cache, database should only be queried once
+ // Note: React.cache behavior is per-request in production, but in tests
+ // we can verify the function was called multiple times
+ expect(prisma.environment.findUnique).toHaveBeenCalled();
+ });
+
+ test("returns different data for different environmentIds", async () => {
+ vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
+ id: "env123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "proj123",
+ appSetupCompleted: true,
+ project: {
+ id: "proj123",
+ name: "Project 1",
+ organizationId: "org123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ languages: ["en"],
+ recontactDays: 7,
+ linkSurveyBranding: true,
+ inAppSurveyBranding: true,
+ config: {},
+ placement: "bottomRight",
+ clickOutsideClose: true,
+ darkOverlay: false,
+ styling: {},
+ logo: null,
+ environments: [],
+ organization: {
+ id: "org123",
+ name: "Org 1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: { plan: "free", limits: {} },
+ isAIEnabled: false,
+ whitelabel: false,
+ memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
+ },
+ },
+ } as any);
+
+ const result1 = await getEnvironmentLayoutData("env123", "user123");
+
+ vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
+ id: "env456",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "development",
+ projectId: "proj456",
+ appSetupCompleted: true,
+ project: {
+ id: "proj456",
+ name: "Project 2",
+ organizationId: "org456",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ languages: ["en"],
+ recontactDays: 7,
+ linkSurveyBranding: true,
+ inAppSurveyBranding: true,
+ config: {},
+ placement: "bottomRight",
+ clickOutsideClose: true,
+ darkOverlay: false,
+ styling: {},
+ logo: null,
+ environments: [],
+ organization: {
+ id: "org456",
+ name: "Org 2",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: { plan: "pro", limits: {} },
+ isAIEnabled: true,
+ whitelabel: true,
+ memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
+ },
+ },
+ } as any);
+
+ const result2 = await getEnvironmentLayoutData("env456", "user123");
+
+ expect(result1.environment.id).not.toBe(result2.environment.id);
+ });
+ });
});
diff --git a/apps/web/modules/environments/lib/utils.ts b/apps/web/modules/environments/lib/utils.ts
index ebcc0829ea..3d4b54c504 100644
--- a/apps/web/modules/environments/lib/utils.ts
+++ b/apps/web/modules/environments/lib/utils.ts
@@ -1,18 +1,30 @@
+import { Prisma } from "@prisma/client";
import { getServerSession } from "next-auth";
import { cache as reactCache } from "react";
-import { AuthorizationError } from "@formbricks/types/errors";
+import { prisma } from "@formbricks/database";
+import { logger } from "@formbricks/logger";
+import { ZId } from "@formbricks/types/common";
+import { AuthorizationError, DatabaseError } from "@formbricks/types/errors";
+import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
-import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
+import {
+ getMonthlyActiveOrganizationPeopleCount,
+ getMonthlyOrganizationResponseCount,
+ getOrganizationByEnvironmentId,
+} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
+import { validateInputs } from "@/lib/utils/validate";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
+import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
+import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
-import { TEnvironmentAuth } from "../types/environment-auth";
+import { TEnvironmentAuth, TEnvironmentLayoutData } from "../types/environment-auth";
/**
* Common utility to fetch environment data and perform authorization checks
@@ -103,3 +115,215 @@ export const environmentIdLayoutChecks = async (environmentId: string) => {
return { t, session, user, organization };
};
+
+/**
+ * Fetches environment with related project, organization, environments, and current user's membership
+ * in a single optimized database query.
+ * Returns data with proper types matching TEnvironment, TProject, TOrganization.
+ *
+ * Note: Validation is handled by parent function (getEnvironmentLayoutData)
+ */
+export const getEnvironmentWithRelations = reactCache(async (environmentId: string, userId: string) => {
+ try {
+ const data = await prisma.environment.findUnique({
+ where: { id: environmentId },
+ select: {
+ // Environment fields
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ type: true,
+ projectId: true,
+ appSetupCompleted: true,
+ // Project via relation (nested select)
+ project: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ name: true,
+ organizationId: true,
+ languages: true,
+ recontactDays: true,
+ linkSurveyBranding: true,
+ inAppSurveyBranding: true,
+ config: true,
+ placement: true,
+ clickOutsideClose: true,
+ darkOverlay: true,
+ styling: true,
+ logo: true,
+ // All project environments
+ environments: {
+ select: {
+ id: true,
+ type: true,
+ createdAt: true,
+ updatedAt: true,
+ projectId: true,
+ appSetupCompleted: true,
+ },
+ },
+ // Organization via relation
+ organization: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ name: true,
+ billing: true,
+ isAIEnabled: true,
+ whitelabel: true,
+ // Current user's membership only (filtered at DB level)
+ memberships: {
+ where: {
+ userId: userId,
+ },
+ select: {
+ userId: true,
+ organizationId: true,
+ accepted: true,
+ role: true,
+ },
+ take: 1, // Only need one result
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!data) return null;
+
+ // Extract and return properly typed data
+ return {
+ environment: {
+ id: data.id,
+ createdAt: data.createdAt,
+ updatedAt: data.updatedAt,
+ type: data.type,
+ projectId: data.projectId,
+ appSetupCompleted: data.appSetupCompleted,
+ },
+ project: {
+ id: data.project.id,
+ createdAt: data.project.createdAt,
+ updatedAt: data.project.updatedAt,
+ name: data.project.name,
+ organizationId: data.project.organizationId,
+ languages: data.project.languages,
+ recontactDays: data.project.recontactDays,
+ linkSurveyBranding: data.project.linkSurveyBranding,
+ inAppSurveyBranding: data.project.inAppSurveyBranding,
+ config: data.project.config,
+ placement: data.project.placement,
+ clickOutsideClose: data.project.clickOutsideClose,
+ darkOverlay: data.project.darkOverlay,
+ styling: data.project.styling,
+ logo: data.project.logo,
+ environments: data.project.environments,
+ },
+ organization: {
+ id: data.project.organization.id,
+ createdAt: data.project.organization.createdAt,
+ updatedAt: data.project.organization.updatedAt,
+ name: data.project.organization.name,
+ billing: data.project.organization.billing,
+ isAIEnabled: data.project.organization.isAIEnabled,
+ whitelabel: data.project.organization.whitelabel,
+ },
+ environments: data.project.environments,
+ membership: data.project.organization.memberships[0] || null, // First (and only) membership or null
+ };
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
+ logger.error(error, "Error getting environment with relations");
+ throw new DatabaseError(error.message);
+ }
+ throw error;
+ }
+});
+
+/**
+ * Fetches all data required for environment layout rendering.
+ * Consolidates multiple queries and eliminates duplicates.
+ * Does NOT fetch switcher data (organizations/projects lists) - those are lazy-loaded.
+ *
+ * Note: userId is included in cache key to make it explicit that results are user-specific,
+ * even though React.cache() is per-request and doesn't leak across users.
+ */
+export const getEnvironmentLayoutData = reactCache(
+ async (environmentId: string, userId: string): Promise => {
+ validateInputs([environmentId, ZId]);
+ validateInputs([userId, ZId]);
+
+ const t = await getTranslate();
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error(t("common.session_not_found"));
+ }
+
+ // Verify userId matches session (safety check)
+ if (session.user.id !== userId) {
+ throw new Error("User ID mismatch with session");
+ }
+
+ // Get user first (lightweight query needed for subsequent checks)
+ const user = await getUser(userId); // 1 DB query
+ if (!user) {
+ throw new Error(t("common.user_not_found"));
+ }
+
+ // Authorization check before expensive data fetching
+ const hasAccess = await hasUserEnvironmentAccess(userId, environmentId);
+ if (!hasAccess) {
+ throw new AuthorizationError(t("common.not_authorized"));
+ }
+
+ const relationData = await getEnvironmentWithRelations(environmentId, userId);
+ if (!relationData) {
+ throw new Error(t("common.environment_not_found"));
+ }
+
+ const { environment, project, organization, environments, membership } = relationData;
+
+ // Validate user's membership was found
+ if (!membership) {
+ throw new Error(t("common.membership_not_found"));
+ }
+
+ // Fetch remaining data in parallel
+ const [isAccessControlAllowed, projectPermission, license] = await Promise.all([
+ getAccessControlPermission(organization.billing.plan), // No DB query (logic only)
+ getProjectPermissionByUserId(userId, environment.projectId), // 1 DB query
+ getEnterpriseLicense(), // Externally cached
+ ]);
+
+ // Conditional queries for Formbricks Cloud
+ let peopleCount = 0;
+ let responseCount = 0;
+ if (IS_FORMBRICKS_CLOUD) {
+ [peopleCount, responseCount] = await Promise.all([
+ getMonthlyActiveOrganizationPeopleCount(organization.id),
+ getMonthlyOrganizationResponseCount(organization.id),
+ ]);
+ }
+
+ return {
+ session,
+ user,
+ environment,
+ project,
+ organization,
+ environments,
+ membership,
+ isAccessControlAllowed,
+ projectPermission,
+ license,
+ peopleCount,
+ responseCount,
+ };
+ }
+);
diff --git a/apps/web/modules/environments/types/environment-auth.ts b/apps/web/modules/environments/types/environment-auth.ts
index 7c087de969..fd96a658f7 100644
--- a/apps/web/modules/environments/types/environment-auth.ts
+++ b/apps/web/modules/environments/types/environment-auth.ts
@@ -1,10 +1,21 @@
+import { Session } from "next-auth";
import { z } from "zod";
-import { ZEnvironment } from "@formbricks/types/environment";
-import { ZMembership } from "@formbricks/types/memberships";
-import { ZOrganization } from "@formbricks/types/organizations";
-import { ZProject } from "@formbricks/types/project";
-import { ZUser } from "@formbricks/types/user";
-import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
+import { TEnvironment, ZEnvironment } from "@formbricks/types/environment";
+import { TMembership, ZMembership } from "@formbricks/types/memberships";
+import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
+import { TProject, ZProject } from "@formbricks/types/project";
+import { TUser, ZUser } from "@formbricks/types/user";
+import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
+import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
+
+// Type for the enterprise license returned by getEnterpriseLicense()
+type TEnterpriseLicense = {
+ active: boolean;
+ features: TEnterpriseLicenseFeatures | null;
+ lastChecked: Date;
+ isPendingDowngrade: boolean;
+ fallbackLevel: string;
+};
export const ZEnvironmentAuth = z.object({
environment: ZEnvironment,
@@ -27,3 +38,25 @@ export const ZEnvironmentAuth = z.object({
});
export type TEnvironmentAuth = z.infer;
+
+/**
+ * Complete layout data type for environment pages.
+ * Includes all data needed for layout rendering.
+ *
+ * Note: organizations and projects lists are NOT included - they are lazy-loaded
+ * in switcher dropdowns only when needed.
+ */
+export type TEnvironmentLayoutData = {
+ session: Session;
+ user: TUser;
+ environment: TEnvironment;
+ project: TProject; // Current project with full details
+ organization: TOrganization;
+ environments: TEnvironment[]; // All project environments for switcher
+ membership: TMembership;
+ isAccessControlAllowed: boolean;
+ projectPermission: TTeamPermission | null;
+ license: TEnterpriseLicense;
+ peopleCount: number;
+ responseCount: number;
+};
diff --git a/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.tsx b/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.tsx
index f307e36a18..a5fb04c3fc 100644
--- a/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.tsx
+++ b/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.tsx
@@ -46,7 +46,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>