From 718a199d5bdc54f9d33c844da721e97387a732d8 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:37:23 -0800 Subject: [PATCH] feat: add Personal Link generation UI (#6819) Co-authored-by: Dhruwang --- apps/web/i18n.lock | 11 + apps/web/locales/de-DE.json | 11 + apps/web/locales/en-US.json | 11 + apps/web/locales/es-ES.json | 11 + apps/web/locales/fr-FR.json | 11 + apps/web/locales/ja-JP.json | 11 + 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 | 11 + apps/web/locales/zh-Hans-CN.json | 11 + apps/web/locales/zh-Hant-TW.json | 11 + .../ee/contacts/[contactId]/actions.ts | 60 ++++++ .../components/contact-control-bar.tsx | 99 +++++++++ .../generate-personal-link-modal.tsx | 190 ++++++++++++++++++ .../modules/ee/contacts/[contactId]/page.tsx | 13 +- .../modules/ee/contacts/lib/surveys.test.ts | 130 ++++++++++++ apps/web/modules/ee/contacts/lib/surveys.ts | 32 +++ 18 files changed, 651 insertions(+), 5 deletions(-) create mode 100644 apps/web/modules/ee/contacts/[contactId]/actions.ts create mode 100644 apps/web/modules/ee/contacts/[contactId]/components/contact-control-bar.tsx create mode 100644 apps/web/modules/ee/contacts/[contactId]/components/generate-personal-link-modal.tsx create mode 100644 apps/web/modules/ee/contacts/lib/surveys.test.ts create mode 100644 apps/web/modules/ee/contacts/lib/surveys.ts diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 31d0a7efbd..f17e3e395b 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -183,6 +183,7 @@ checksums: common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51 common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0 common/expand_rows: b6e06327cb8718dfd6651720843e4dad + common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784 common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3 common/finish: ffa7a10f71182b48fefed7135bee24fa @@ -191,6 +192,7 @@ checksums: common/full_name: f45991923345e8322c9ff8cd6b7e2b16 common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef common/general: b891e8f15579fc5d97bcaf3637f5ae59 + common/generate: 0345bf322c191e70d01fd6607ec5c2f8 common/go_back: b917ea82facb90c88c523b255d29f84b common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2 common/hidden: fa290c6ada5869d744ed35e9cca64699 @@ -560,9 +562,18 @@ checksums: environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8 environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616 + environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8 + environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6 + environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe + environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2 + environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d + environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53 + environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604 + environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12 + environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514 environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79 environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 52a014d0f5..06f7c1fd5a 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -210,6 +210,7 @@ "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_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage", "failed_to_load_organizations": "Fehler beim Laden der Organisationen", "failed_to_load_projects": "Fehler beim Laden der Projekte", "finish": "Fertigstellen", @@ -218,6 +219,7 @@ "full_name": "Name", "gathering_responses": "Antworten sammeln", "general": "Allgemein", + "generate": "Generieren", "go_back": "Geh zurück", "go_to_dashboard": "Zum Dashboard gehen", "hidden": "Versteckt", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", "delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.", "delete_contact_confirmation_with_quotas": "{value, plural, other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}", + "generate_personal_link": "Persönlichen Link generieren", + "generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.", + "no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.", + "no_published_surveys": "Keine veröffentlichten Umfragen", "no_responses_found": "Keine Antworten gefunden", "not_provided": "Nicht angegeben", + "personal_link_generated": "Persönlicher Link erfolgreich generiert", + "personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}", + "personal_survey_link": "Link zur persönlichen Umfrage", + "please_select_a_survey": "Bitte wähle eine Umfrage aus", "search_contact": "Kontakt suchen", + "select_a_survey": "Wähle eine Umfrage aus", "select_attribute": "Attribut auswählen", "unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen", "unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index cb88d69694..ed290035c2 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -210,6 +210,7 @@ "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_copy_to_clipboard": "Failed to copy to clipboard", "failed_to_load_organizations": "Failed to load organizations", "failed_to_load_projects": "Failed to load projects", "finish": "Finish", @@ -218,6 +219,7 @@ "full_name": "Full name", "gathering_responses": "Gathering responses", "general": "General", + "generate": "Generate", "go_back": "Go Back", "go_to_dashboard": "Go to Dashboard", "hidden": "Hidden", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contacts refreshed successfully", "delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.", "delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}", + "generate_personal_link": "Generate Personal Link", + "generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.", + "no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.", + "no_published_surveys": "No published surveys", "no_responses_found": "No responses found", "not_provided": "Not provided", + "personal_link_generated": "Personal link generated successfully", + "personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}", + "personal_survey_link": "Personal Survey Link", + "please_select_a_survey": "Please select a survey", "search_contact": "Search contact", + "select_a_survey": "Select a survey", "select_attribute": "Select Attribute", "unlock_contacts_description": "Manage contacts and send out targeted surveys", "unlock_contacts_title": "Unlock contacts with a higher plan", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 29e3f5c144..feb68d6303 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -210,6 +210,7 @@ "error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.", "error_rate_limit_title": "Límite de frecuencia excedido", "expand_rows": "Expandir filas", + "failed_to_copy_to_clipboard": "Error al copiar al portapapeles", "failed_to_load_organizations": "Error al cargar organizaciones", "failed_to_load_projects": "Error al cargar proyectos", "finish": "Finalizar", @@ -218,6 +219,7 @@ "full_name": "Nombre completo", "gathering_responses": "Recopilando respuestas", "general": "General", + "generate": "Generar", "go_back": "Volver", "go_to_dashboard": "Ir al panel de control", "hidden": "Oculto", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contactos actualizados correctamente", "delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.", "delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}", + "generate_personal_link": "Generar enlace personal", + "generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.", + "no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.", + "no_published_surveys": "No hay encuestas publicadas", "no_responses_found": "No se encontraron respuestas", "not_provided": "No proporcionado", + "personal_link_generated": "Enlace personal generado correctamente", + "personal_link_generated_but_clipboard_failed": "Enlace personal generado pero falló al copiar al portapapeles: {url}", + "personal_survey_link": "Enlace personal de encuesta", + "please_select_a_survey": "Por favor, selecciona una encuesta", "search_contact": "Buscar contacto", + "select_a_survey": "Selecciona una encuesta", "select_attribute": "Seleccionar atributo", "unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas", "unlock_contacts_title": "Desbloquea contactos con un plan superior", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index ceba9c7a41..1f9ceb9552 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -210,6 +210,7 @@ "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_copy_to_clipboard": "Échec de la copie dans le presse-papiers", "failed_to_load_organizations": "Échec du chargement des organisations", "failed_to_load_projects": "Échec du chargement des projets", "finish": "Terminer", @@ -218,6 +219,7 @@ "full_name": "Nom complet", "gathering_responses": "Collecte des réponses", "general": "Général", + "generate": "Générer", "go_back": "Retourner", "go_to_dashboard": "Aller au tableau de bord", "hidden": "Caché", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contacts rafraîchis avec succès", "delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.", "delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}", + "generate_personal_link": "Générer un lien personnel", + "generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.", + "no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.", + "no_published_surveys": "Aucune enquête publiée", "no_responses_found": "Aucune réponse trouvée", "not_provided": "Non fourni", + "personal_link_generated": "Lien personnel généré avec succès", + "personal_link_generated_but_clipboard_failed": "Lien personnel généré mais échec de la copie dans le presse-papiers : {url}", + "personal_survey_link": "Lien vers le sondage personnel", + "please_select_a_survey": "Veuillez sélectionner une enquête", "search_contact": "Rechercher un contact", + "select_a_survey": "Sélectionner une enquête", "select_attribute": "Sélectionner un attribut", "unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées", "unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index fc2c44bfd3..472b3f702e 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -210,6 +210,7 @@ "error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。", "error_rate_limit_title": "レート制限を超えました", "expand_rows": "行を展開", + "failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました", "failed_to_load_organizations": "組織の読み込みに失敗しました", "failed_to_load_projects": "プロジェクトの読み込みに失敗しました", "finish": "完了", @@ -218,6 +219,7 @@ "full_name": "氏名", "gathering_responses": "回答を収集しています", "general": "一般", + "generate": "生成", "go_back": "戻る", "go_to_dashboard": "ダッシュボードへ移動", "hidden": "非表示", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "連絡先を正常に更新しました", "delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。", "delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}", + "generate_personal_link": "個人リンクを生成", + "generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。", + "no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。", + "no_published_surveys": "公開されたフォームはありません", "no_responses_found": "回答が見つかりません", "not_provided": "提供されていません", + "personal_link_generated": "個人リンクが正常に生成されました", + "personal_link_generated_but_clipboard_failed": "個人用リンクは生成されましたが、クリップボードへのコピーに失敗しました: {url}", + "personal_survey_link": "個人調査リンク", + "please_select_a_survey": "フォームを選択してください", "search_contact": "連絡先を検索", + "select_a_survey": "フォームを選択", "select_attribute": "属性を選択", "unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します", "unlock_contacts_title": "上位プランで連絡先をアンロック", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index e269e54511..2c09a907e7 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -210,6 +210,7 @@ "error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.", "error_rate_limit_title": "Tarieflimiet overschreden", "expand_rows": "Vouw rijen uit", + "failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt", "failed_to_load_organizations": "Laden van organisaties mislukt", "failed_to_load_projects": "Laden van projecten mislukt", "finish": "Finish", @@ -218,6 +219,7 @@ "full_name": "Volledige naam", "gathering_responses": "Reacties verzamelen", "general": "Algemeen", + "generate": "Genereren", "go_back": "Ga terug", "go_to_dashboard": "Ga naar Dashboard", "hidden": "Verborgen", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contacten zijn vernieuwd", "delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.", "delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}", + "generate_personal_link": "Persoonlijke link genereren", + "generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.", + "no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.", + "no_published_surveys": "Geen gepubliceerde enquêtes", "no_responses_found": "Geen reacties gevonden", "not_provided": "Niet voorzien", + "personal_link_generated": "Persoonlijke link succesvol gegenereerd", + "personal_link_generated_but_clipboard_failed": "Persoonlijke link gegenereerd maar kopiëren naar klembord mislukt: {url}", + "personal_survey_link": "Persoonlijke enquêtelink", + "please_select_a_survey": "Selecteer een enquête", "search_contact": "Zoek contactpersoon", + "select_a_survey": "Selecteer een enquête", "select_attribute": "Selecteer Kenmerk", "unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes", "unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 9c8e9ceaba..a04bd43802 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -210,6 +210,7 @@ "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_copy_to_clipboard": "Falha ao copiar para a área de transferência", "failed_to_load_organizations": "Falha ao carregar organizações", "failed_to_load_projects": "Falha ao carregar projetos", "finish": "Terminar", @@ -218,6 +219,7 @@ "full_name": "Nome completo", "gathering_responses": "Recolhendo respostas", "general": "Geral", + "generate": "Gerar", "go_back": "Voltar", "go_to_dashboard": "Ir para o Painel", "hidden": "Escondido", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contatos atualizados com sucesso", "delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.", "delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}", + "generate_personal_link": "Gerar link pessoal", + "generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.", + "no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.", + "no_published_surveys": "Sem pesquisas publicadas", "no_responses_found": "Nenhuma resposta encontrada", "not_provided": "Não fornecido", + "personal_link_generated": "Link pessoal gerado com sucesso", + "personal_link_generated_but_clipboard_failed": "Link pessoal gerado, mas falha ao copiar para a área de transferência: {url}", + "personal_survey_link": "Link da pesquisa pessoal", + "please_select_a_survey": "Por favor, selecione uma pesquisa", "search_contact": "Buscar contato", + "select_a_survey": "Selecione uma pesquisa", "select_attribute": "Selecionar Atributo", "unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas", "unlock_contacts_title": "Desbloqueie contatos com um plano superior", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 8f19d518cd..bc0cc29074 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -210,6 +210,7 @@ "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_copy_to_clipboard": "Falha ao copiar para a área de transferência", "failed_to_load_organizations": "Falha ao carregar organizações", "failed_to_load_projects": "Falha ao carregar projetos", "finish": "Concluir", @@ -218,6 +219,7 @@ "full_name": "Nome completo", "gathering_responses": "A recolher respostas", "general": "Geral", + "generate": "Gerar", "go_back": "Voltar", "go_to_dashboard": "Ir para o Painel", "hidden": "Oculto", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contactos atualizados com sucesso", "delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.", "delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}", + "generate_personal_link": "Gerar Link Pessoal", + "generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.", + "no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.", + "no_published_surveys": "Sem inquéritos publicados", "no_responses_found": "Nenhuma resposta encontrada", "not_provided": "Não fornecido", + "personal_link_generated": "Link pessoal gerado com sucesso", + "personal_link_generated_but_clipboard_failed": "Link pessoal gerado mas falha ao copiar para a área de transferência: {url}", + "personal_survey_link": "Link do inquérito pessoal", + "please_select_a_survey": "Por favor, selecione um inquérito", "search_contact": "Procurar contacto", + "select_a_survey": "Selecione um inquérito", "select_attribute": "Selecionar Atributo", "unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados", "unlock_contacts_title": "Desbloqueie os contactos com um plano superior", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 07a97e4534..297307f7b1 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -210,6 +210,7 @@ "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_copy_to_clipboard": "Nu s-a reușit copierea în clipboard", "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ă", @@ -218,6 +219,7 @@ "full_name": "Nume complet", "gathering_responses": "Culegere răspunsuri", "general": "General", + "generate": "Generează", "go_back": "Înapoi", "go_to_dashboard": "Mergi la Tablou de Bord", "hidden": "Ascuns", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "Contactele au fost actualizate cu succes", "delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.", "delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }", + "generate_personal_link": "Generează link personal", + "generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.", + "no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.", + "no_published_surveys": "Nu există sondaje publicate", "no_responses_found": "Nu s-au găsit răspunsuri", "not_provided": "Nu a fost furnizat", + "personal_link_generated": "Linkul personal a fost generat cu succes", + "personal_link_generated_but_clipboard_failed": "Linkul personal a fost generat, dar nu s-a reușit copierea în clipboard: {url}", + "personal_survey_link": "Link către sondajul personal", + "please_select_a_survey": "Vă rugăm să selectați un sondaj", "search_contact": "Căutați contact", + "select_a_survey": "Selectați un sondaj", "select_attribute": "Selectează atributul", "unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite", "unlock_contacts_title": "Deblocați contactele cu un plan superior.", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 638d762c04..c1a7df0e62 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -210,6 +210,7 @@ "error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。", "error_rate_limit_title": "速率 限制 超过", "expand_rows": "展开 行", + "failed_to_copy_to_clipboard": "复制到剪贴板失败", "failed_to_load_organizations": "加载组织失败", "failed_to_load_projects": "加载项目失败", "finish": "完成", @@ -218,6 +219,7 @@ "full_name": "全名", "gathering_responses": "收集反馈", "general": "通用", + "generate": "生成", "go_back": "返回 ", "go_to_dashboard": "转到 Dashboard", "hidden": "隐藏", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "联系人 已成功刷新", "delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。", "delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}", + "generate_personal_link": "生成个人链接", + "generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。", + "no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。", + "no_published_surveys": "没有已发布的调查", "no_responses_found": "未找到 响应", "not_provided": "未提供", + "personal_link_generated": "个人链接生成成功", + "personal_link_generated_but_clipboard_failed": "个性化链接已生成,但复制到剪贴板失败:{url}", + "personal_survey_link": "个人调查链接", + "please_select_a_survey": "请选择一个调查", "search_contact": "搜索 联系人", + "select_a_survey": "选择一个调查", "select_attribute": "选择 属性", "unlock_contacts_description": "管理 联系人 并 发送 定向 调查", "unlock_contacts_title": "通过 更 高级 划解锁 联系人", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index d43a8779ff..be71d9fdce 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -210,6 +210,7 @@ "error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。", "error_rate_limit_title": "限流超過", "expand_rows": "展開列", + "failed_to_copy_to_clipboard": "無法複製到剪貼簿", "failed_to_load_organizations": "無法載入組織", "failed_to_load_projects": "無法載入專案", "finish": "完成", @@ -218,6 +219,7 @@ "full_name": "全名", "gathering_responses": "收集回應中", "general": "一般", + "generate": "產生", "go_back": "返回", "go_to_dashboard": "前往儀表板", "hidden": "隱藏", @@ -596,9 +598,18 @@ "contacts_table_refresh_success": "聯絡人已成功重新整理", "delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。", "delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}", + "generate_personal_link": "產生個人連結", + "generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。", + "no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。", + "no_published_surveys": "沒有已發佈的問卷", "no_responses_found": "找不到回應", "not_provided": "未提供", + "personal_link_generated": "個人連結已成功產生", + "personal_link_generated_but_clipboard_failed": "已生成個人連結,但無法複製到剪貼簿:{url}", + "personal_survey_link": "個人調查連結", + "please_select_a_survey": "請選擇一個問卷", "search_contact": "搜尋聯絡人", + "select_a_survey": "選擇問卷", "select_attribute": "選取屬性", "unlock_contacts_description": "管理聯絡人並發送目標問卷", "unlock_contacts_title": "使用更高等級的方案解鎖聯絡人", diff --git a/apps/web/modules/ee/contacts/[contactId]/actions.ts b/apps/web/modules/ee/contacts/[contactId]/actions.ts new file mode 100644 index 0000000000..240c0e9f90 --- /dev/null +++ b/apps/web/modules/ee/contacts/[contactId]/actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper"; +import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; + +const ZGeneratePersonalSurveyLinkAction = z.object({ + contactId: ZId, + surveyId: ZId, + expirationDays: z.number().optional(), +}); + +export const generatePersonalSurveyLinkAction = authenticatedActionClient + .schema(ZGeneratePersonalSurveyLinkAction) + .action(async ({ ctx, parsedInput }) => { + const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId); + const projectId = await getProjectIdFromContactId(parsedInput.contactId); + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId, + }, + ], + }); + + const result = await getContactSurveyLink( + parsedInput.contactId, + parsedInput.surveyId, + parsedInput.expirationDays + ); + + if (!result.ok) { + if (result.error.type === "not_found") { + throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + } + if (result.error.type === "bad_request") { + const errorMessage = result.error.details?.[0]?.issue || "Invalid request"; + throw new InvalidInputError(errorMessage); + } + const errorMessage = result.error.details?.[0]?.issue || "Failed to generate personal survey link"; + throw new InvalidInputError(errorMessage); + } + + return { + surveyUrl: result.data, + }; + }); diff --git a/apps/web/modules/ee/contacts/[contactId]/components/contact-control-bar.tsx b/apps/web/modules/ee/contacts/[contactId]/components/contact-control-bar.tsx new file mode 100644 index 0000000000..d90d7caead --- /dev/null +++ b/apps/web/modules/ee/contacts/[contactId]/components/contact-control-bar.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { LinkIcon, TrashIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { deleteContactAction } from "@/modules/ee/contacts/actions"; +import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys"; +import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; +import { IconBar } from "@/modules/ui/components/iconbar"; +import { GeneratePersonalLinkModal } from "./generate-personal-link-modal"; + +interface ContactControlBarProps { + environmentId: string; + contactId: string; + isReadOnly: boolean; + isQuotasAllowed: boolean; + publishedLinkSurveys: PublishedLinkSurvey[]; +} + +export const ContactControlBar = ({ + environmentId, + contactId, + isReadOnly, + isQuotasAllowed, + publishedLinkSurveys, +}: ContactControlBarProps) => { + const router = useRouter(); + const { t } = useTranslation(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDeletingPerson, setIsDeletingPerson] = useState(false); + const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false); + + const handleDeletePerson = async () => { + setIsDeletingPerson(true); + const deletePersonResponse = await deleteContactAction({ contactId }); + if (deletePersonResponse?.data) { + router.refresh(); + router.push(`/environments/${environmentId}/contacts`); + toast.success(t("environments.contacts.contact_deleted_successfully")); + } else { + const errorMessage = getFormattedErrorMessage(deletePersonResponse); + toast.error(errorMessage); + } + setIsDeletingPerson(false); + setDeleteDialogOpen(false); + }; + + if (isReadOnly) { + return null; + } + + const iconActions = [ + { + icon: LinkIcon, + tooltip: t("environments.contacts.generate_personal_link"), + onClick: () => { + setIsGenerateLinkModalOpen(true); + }, + isVisible: true, + }, + { + icon: TrashIcon, + tooltip: t("common.delete"), + onClick: () => { + setDeleteDialogOpen(true); + }, + isVisible: true, + }, + ]; + + return ( + <> + + + + + ); +}; diff --git a/apps/web/modules/ee/contacts/[contactId]/components/generate-personal-link-modal.tsx b/apps/web/modules/ee/contacts/[contactId]/components/generate-personal-link-modal.tsx new file mode 100644 index 0000000000..ca2cdfc073 --- /dev/null +++ b/apps/web/modules/ee/contacts/[contactId]/components/generate-personal-link-modal.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { CopyIcon, LinkIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys"; +import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; +import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; +import { generatePersonalSurveyLinkAction } from "../actions"; + +interface GeneratePersonalLinkModalProps { + open: boolean; + setOpen: (open: boolean) => void; + contactId: string; + publishedLinkSurveys: PublishedLinkSurvey[]; +} + +const copyToClipboard = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + console.warn("Failed to copy to clipboard:", error); + return false; + } +}; + +export const GeneratePersonalLinkModal = ({ + open, + setOpen, + contactId, + publishedLinkSurveys, +}: GeneratePersonalLinkModalProps) => { + const { t } = useTranslation(); + const [selectedSurveyId, setSelectedSurveyId] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [generatedUrl, setGeneratedUrl] = useState(undefined); + + useEffect(() => { + if (open) { + } else { + setSelectedSurveyId(undefined); + setGeneratedUrl(""); + } + }, [open]); + + const handleCopyUrl = useCallback( + async (url: string) => { + const success = await copyToClipboard(url); + if (success) { + toast.success(t("common.copied_to_clipboard")); + } else { + toast.error(t("common.failed_to_copy_to_clipboard")); + } + }, + [t] + ); + + const handleGenerate = async () => { + if (!selectedSurveyId) { + toast.error(t("environments.contacts.please_select_a_survey")); + return; + } + setIsLoading(true); + const response = await generatePersonalSurveyLinkAction({ + contactId, + surveyId: selectedSurveyId, + }); + + if (response?.data) { + const surveyUrl = response?.data?.surveyUrl; + if (!surveyUrl) { + toast.error(t("common.something_went_wrong_please_try_again")); + return; + } + + setGeneratedUrl(surveyUrl); + const success = await copyToClipboard(surveyUrl); + + if (success) { + toast.success(t("common.copied_to_clipboard")); + } else { + toast.error( + t("environments.contacts.personal_link_generated_but_clipboard_failed", { + url: surveyUrl, + }) || `${t("environments.contacts.personal_link_generated")}: ${surveyUrl}`, + { duration: 6000 } + ); + } + } else { + const errorMessage = getFormattedErrorMessage(response); + toast.error(errorMessage || t("common.something_went_wrong_please_try_again")); + return; + } + setIsLoading(false); + }; + + const getSelectPlaceholder = () => { + if (publishedLinkSurveys.length === 0) return t("environments.contacts.no_published_surveys"); + return t("environments.contacts.select_a_survey"); + }; + + const isDisabled = isLoading || publishedLinkSurveys.length === 0; + const canGenerate = selectedSurveyId && !isDisabled; + + return ( + + + + + {t("environments.contacts.generate_personal_link")} + + {t("environments.contacts.generate_personal_link_description")} + + + + +
+
+ + + {publishedLinkSurveys.length === 0 && ( +

+ {t("environments.contacts.no_published_link_surveys_available")} +

+ )} +
+ + {generatedUrl && ( +
+ +
+ + +
+
+ )} +
+
+ + + + + +
+
+ ); +}; diff --git a/apps/web/modules/ee/contacts/[contactId]/page.tsx b/apps/web/modules/ee/contacts/[contactId]/page.tsx index 3ad221103a..31c414e8da 100644 --- a/apps/web/modules/ee/contacts/[contactId]/page.tsx +++ b/apps/web/modules/ee/contacts/[contactId]/page.tsx @@ -1,9 +1,10 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTranslate } from "@/lingodotdev/server"; import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section"; -import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/components/delete-contact-button"; +import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar"; import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes"; import { getContact } from "@/modules/ee/contacts/lib/contacts"; +import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys"; import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; @@ -19,10 +20,11 @@ export const SingleContactPage = async (props: { const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId); - const [environmentTags, contact, contactAttributes] = await Promise.all([ + const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([ getTagsByEnvironmentId(params.environmentId), getContact(params.contactId), getContactAttributes(params.contactId), + getPublishedLinkSurveys(params.environmentId), ]); if (!contact) { @@ -31,20 +33,21 @@ export const SingleContactPage = async (props: { const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan); - const getDeletePersonButton = () => { + const getContactControlBar = () => { return ( - ); }; return ( - +
diff --git a/apps/web/modules/ee/contacts/lib/surveys.test.ts b/apps/web/modules/ee/contacts/lib/surveys.test.ts new file mode 100644 index 0000000000..1db426ea49 --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/surveys.test.ts @@ -0,0 +1,130 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getPublishedLinkSurveys } from "./surveys"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + findMany: vi.fn(), + }, + }, +})); + +const environmentId = "cm123456789012345678901237"; + +describe("getPublishedLinkSurveys", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns published link surveys", async () => { + const mockSurveys = [ + { id: "survey1", name: "Customer Feedback Survey" }, + { id: "survey2", name: "Product Survey" }, + { id: "survey3", name: "NPS Survey" }, + ]; + + vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys); + + const result = await getPublishedLinkSurveys(environmentId); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: "survey1", name: "Customer Feedback Survey" }); + expect(result[1]).toEqual({ id: "survey2", name: "Product Survey" }); + expect(result[2]).toEqual({ id: "survey3", name: "NPS Survey" }); + + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId, status: "inProgress", type: "link" }, + select: { + id: true, + name: true, + }, + }); + }); + + test("returns empty array if no published link surveys", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([]); + + const result = await getPublishedLinkSurveys(environmentId); + + expect(result).toEqual([]); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + + test("throws DatabaseError on Prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2002", + clientVersion: "1.0.0", + }); + + vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); + + await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow(DatabaseError); + await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow("DB error"); + }); + + test("throws original error on unknown error", async () => { + const genericError = new Error("Unknown error"); + + vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError); + + await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow(genericError); + await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow("Unknown error"); + }); + + test("filters surveys by status inProgress", async () => { + const mockSurveys = [{ id: "survey1", name: "Active Survey" }]; + + vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys); + + await getPublishedLinkSurveys(environmentId); + + expect(prisma.survey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: "inProgress", + }), + }) + ); + }); + + test("filters surveys by type link", async () => { + const mockSurveys = [{ id: "survey1", name: "Link Survey" }]; + + vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys); + + await getPublishedLinkSurveys(environmentId); + + expect(prisma.survey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + type: "link", + }), + }) + ); + }); + + test("only selects id and name fields", async () => { + const mockSurveys = [{ id: "survey1", name: "Test Survey" }]; + + vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys); + + const result = await getPublishedLinkSurveys(environmentId); + + expect(prisma.survey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { + id: true, + name: true, + }, + }) + ); + + // Verify the result only contains id and name + expect(Object.keys(result[0])).toEqual(["id", "name"]); + }); +}); diff --git a/apps/web/modules/ee/contacts/lib/surveys.ts b/apps/web/modules/ee/contacts/lib/surveys.ts new file mode 100644 index 0000000000..313eb5e319 --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/surveys.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; + +export interface PublishedLinkSurvey { + id: string; + name: string; +} + +export const getPublishedLinkSurveys = reactCache( + async (environmentId: string): Promise => { + try { + const surveys = await prisma.survey.findMany({ + where: { environmentId, status: "inProgress", type: "link" }, + select: { + id: true, + name: true, + }, + }); + + return surveys; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +);