diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 458cecf756..a86d987e86 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2638,7 +2638,7 @@ checksums: workspace/surveys/edit/ai_instance_not_configured: 939ad7c3240fa8de98a325239f1b36bc workspace/surveys/edit/ai_smart_tools_disabled: 13df84ae47d35dfa6e86ffa62f29c75d workspace/surveys/edit/ai_translate: f25943cdeffe155ee524428f4daa5da2 - workspace/surveys/edit/ai_translating: fce4992a1d766124c3e25ef279785a2b + workspace/surveys/edit/ai_translating: 098a2293b39f9f258d67f926cf03df37 workspace/surveys/edit/ai_translation_all_fields_populated: d78f6a663ea19ce77045970179bd200f workspace/surveys/edit/ai_translation_complete: f443d0801404f728e68000b46ca67598 workspace/surveys/edit/ai_translation_failed: fd356a173d0abde7a0fc660394954cc7 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 9a7c69cbc8..4c5ac04462 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.", "ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.", "ai_translate": "Mit KI übersetzen", - "ai_translating": "Übersetze mit KI...", + "ai_translating": "Übersetze mit KI... Bitte lasse dieses Fenster geöffnet.", "ai_translation_all_fields_populated": "Alle Felder sind bereits übersetzt", "ai_translation_complete": "KI-Übersetzung abgeschlossen", "ai_translation_failed": "Übersetzung fehlgeschlagen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 4d9285adef..86b383606b 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AI is not configured. Contact your administrator.", "ai_smart_tools_disabled": "AI smart tools are disabled for this organization.", "ai_translate": "Translate with AI", - "ai_translating": "Translating with AI...", + "ai_translating": "Translating with AI... Please keep this modal open.", "ai_translation_all_fields_populated": "All fields are already translated", "ai_translation_complete": "AI translation complete", "ai_translation_failed": "Translation failed", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 6df13b3c98..a31a3a81bd 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.", "ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.", "ai_translate": "Traducir con IA", - "ai_translating": "Traduciendo con IA...", + "ai_translating": "Traduciendo con IA... Por favor, mantén este modal abierto.", "ai_translation_all_fields_populated": "Todos los campos ya están traducidos", "ai_translation_complete": "Traducción con IA completada", "ai_translation_failed": "La traducción ha fallado", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 6622ab7fa8..d08c99cc6e 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.", "ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.", "ai_translate": "Traduire avec l'IA", - "ai_translating": "Traduction avec l'IA en cours...", + "ai_translating": "Traduction en cours avec l'IA... Garde cette fenêtre ouverte.", "ai_translation_all_fields_populated": "Tous les champs sont déjà traduits", "ai_translation_complete": "Traduction IA terminée", "ai_translation_failed": "La traduction a échoué", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 325a82ee15..e74d22970d 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.", "ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.", "ai_translate": "Fordítás mesterséges intelligenciával", - "ai_translating": "Fordítás mesterséges intelligenciával...", + "ai_translating": "AI fordítás folyamatban... Kérjük, tartsa nyitva ezt az ablakot.", "ai_translation_all_fields_populated": "Minden mező már le van fordítva", "ai_translation_complete": "A mesterséges intelligencia által végzett fordítás befejeződött", "ai_translation_failed": "A fordítás sikertelen volt", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index ae12c135ed..ef32c09847 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。", "ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。", "ai_translate": "AIで翻訳", - "ai_translating": "AIで翻訳中...", + "ai_translating": "AIで翻訳中... このモーダルを開いたままにしてください。", "ai_translation_all_fields_populated": "すべてのフィールドは既に翻訳されています", "ai_translation_complete": "AI翻訳が完了しました", "ai_translation_failed": "翻訳に失敗しました", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 1b1e9a7d25..ea9bb4361e 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.", "ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.", "ai_translate": "Vertalen met AI", - "ai_translating": "Bezig met vertalen via AI...", + "ai_translating": "Vertalen met AI... Laat dit venster open.", "ai_translation_all_fields_populated": "Alle velden zijn al vertaald", "ai_translation_complete": "AI-vertaling voltooid", "ai_translation_failed": "Vertaling mislukt", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index c29e79b256..e4bbcf4610 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.", "ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.", "ai_translate": "Traduzir com IA", - "ai_translating": "Traduzindo com IA...", + "ai_translating": "Traduzindo com IA... Por favor, mantenha este modal aberto.", "ai_translation_all_fields_populated": "Todos os campos já estão traduzidos", "ai_translation_complete": "Tradução com IA concluída", "ai_translation_failed": "Falha na tradução", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index c3ec8f498e..b68fa76bc0 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.", "ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.", "ai_translate": "Traduzir com IA", - "ai_translating": "A traduzir com IA...", + "ai_translating": "A traduzir com IA... Mantém esta janela aberta, por favor.", "ai_translation_all_fields_populated": "Todos os campos já estão traduzidos", "ai_translation_complete": "Tradução com IA concluída", "ai_translation_failed": "A tradução falhou", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 73eec8b657..61ad892e77 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.", "ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.", "ai_translate": "Traduce cu AI", - "ai_translating": "Se traduce cu AI...", + "ai_translating": "Se traduce cu AI... Te rugăm să ții această fereastră deschisă.", "ai_translation_all_fields_populated": "Toate câmpurile sunt deja traduse", "ai_translation_complete": "Traducerea AI finalizată", "ai_translation_failed": "Traducerea a eșuat", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index db9daff8fb..964729121b 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.", "ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.", "ai_translate": "Перевести с помощью ИИ", - "ai_translating": "Перевод с помощью ИИ...", + "ai_translating": "Перевод с помощью ИИ... Пожалуйста, не закрывай это окно.", "ai_translation_all_fields_populated": "Все поля уже переведены", "ai_translation_complete": "Перевод с помощью ИИ завершён", "ai_translation_failed": "Перевод не удался", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 5b3f3b3710..23b7fcef85 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.", "ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.", "ai_translate": "Översätt med AI", - "ai_translating": "Översätter med AI...", + "ai_translating": "Översätter med AI... Vänligen håll denna dialogruta öppen.", "ai_translation_all_fields_populated": "Alla fält är redan översatta", "ai_translation_complete": "AI-översättning klar", "ai_translation_failed": "Översättningen misslyckades", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 0478d86522..72b776b5fe 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.", "ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.", "ai_translate": "Yapay Zeka ile Çevir", - "ai_translating": "Yapay Zeka ile çevriliyor...", + "ai_translating": "Yapay zeka ile çevriliyor... Lütfen bu pencereyi açık tutun.", "ai_translation_all_fields_populated": "Tüm alanlar zaten çevrilmiş", "ai_translation_complete": "Yapay Zeka çevirisi tamamlandı", "ai_translation_failed": "Çeviri başarısız oldu", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 4f08490ac1..644b23102f 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AI 未配置。请联系您的管理员。", "ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。", "ai_translate": "使用 AI 翻译", - "ai_translating": "正在使用 AI 翻译...", + "ai_translating": "AI 翻译中...请保持此窗口打开。", "ai_translation_all_fields_populated": "所有字段均已翻译", "ai_translation_complete": "AI 翻译完成", "ai_translation_failed": "翻译失败", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 410d167493..bd27ae6ff9 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -2757,7 +2757,7 @@ "ai_instance_not_configured": "AI 未設定。請聯絡您的管理員。", "ai_smart_tools_disabled": "此組織已停用 AI 智慧工具。", "ai_translate": "使用 AI 翻譯", - "ai_translating": "AI 翻譯中...", + "ai_translating": "正在使用 AI 翻譯...請保持此視窗開啟。", "ai_translation_all_fields_populated": "所有欄位都已翻譯", "ai_translation_complete": "AI 翻譯完成", "ai_translation_failed": "翻譯失敗", diff --git a/apps/web/modules/ee/ai-translation/lib/actions.ts b/apps/web/modules/ee/ai-translation/lib/actions.ts index 5045c1281b..5efff059c6 100644 --- a/apps/web/modules/ee/ai-translation/lib/actions.ts +++ b/apps/web/modules/ee/ai-translation/lib/actions.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { enqueueAITranslationJob } from "@formbricks/jobs"; import { ZId } from "@formbricks/types/common"; +import { OperationNotAllowedError } from "@formbricks/types/errors"; import { assertOrganizationAIConfigured } from "@/lib/ai/service"; import { cache } from "@/lib/cache"; import { authenticatedActionClient } from "@/lib/utils/action-client"; @@ -119,7 +120,7 @@ export const getAITranslationResultAction = authenticatedActionClient // Verify the requesting user owns this translation result if (result.data.userId !== ctx.user.id) { - throw new Error("Not authorized"); + throw new OperationNotAllowedError("Not authorized"); } // Check if the job failed diff --git a/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.test.ts b/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.test.ts new file mode 100644 index 0000000000..469929fd7b --- /dev/null +++ b/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { JobExecutionContext } from "@formbricks/jobs"; + +const mocks = vi.hoisted(() => ({ + generateOrganizationAIText: vi.fn(), + cacheSet: vi.fn(), + loggerInfo: vi.fn(), + loggerError: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/ai/service", () => ({ generateOrganizationAIText: mocks.generateOrganizationAIText })); +vi.mock("@/lib/cache", () => ({ cache: { set: mocks.cacheSet } })); +vi.mock("@formbricks/logger", () => ({ + logger: { info: mocks.loggerInfo, error: mocks.loggerError }, +})); + +const makeData = () => ({ + organizationId: "org_1", + workspaceId: "ws_1", + userId: "user_1", + fields: [ + { path: "q1.headline", defaultText: "Hello", isRichText: false }, + { path: "q1.subheader", defaultText: "World", isRichText: true }, + ], + sourceLanguage: "English", + targetLanguage: "German", +}); + +const makeContext = (overrides?: Partial): JobExecutionContext => ({ + attempt: 1, + jobId: "42", + jobName: "ai-translation.translate", + maxAttempts: 3, + queueName: "background-jobs", + ...overrides, +}); + +describe("processAITranslationJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheSet.mockResolvedValue({ ok: true }); + }); + + test("parses AI response, filters to valid keys, and caches result", async () => { + mocks.generateOrganizationAIText.mockResolvedValue({ + text: '{"q1.headline":"Hallo","q1.subheader":"Welt","extra":"ignored"}', + }); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await processAITranslationJob(makeData(), makeContext()); + + expect(mocks.cacheSet).toHaveBeenCalledWith( + "ai-translation-result:42", + { userId: "user_1", translations: { "q1.headline": "Hallo", "q1.subheader": "Welt" } }, + 300_000 + ); + expect(mocks.loggerInfo).toHaveBeenCalled(); + }); + + test("extracts JSON from markdown code fences", async () => { + mocks.generateOrganizationAIText.mockResolvedValue({ + text: '```json\n{"q1.headline":"Hallo"}\n```', + }); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await processAITranslationJob(makeData(), makeContext()); + + expect(mocks.cacheSet).toHaveBeenCalledWith( + "ai-translation-result:42", + expect.objectContaining({ translations: expect.objectContaining({ "q1.headline": "Hallo" }) }), + 300_000 + ); + }); + + test("throws when AI response contains no JSON object", async () => { + mocks.generateOrganizationAIText.mockResolvedValue({ text: "no json here" }); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await expect(processAITranslationJob(makeData(), makeContext())).rejects.toThrow( + "Failed to parse AI translation response" + ); + }); + + test("throws when cache.set fails on success path", async () => { + mocks.generateOrganizationAIText.mockResolvedValue({ text: '{"q1.headline":"Hallo"}' }); + mocks.cacheSet.mockResolvedValue({ ok: false, error: { code: "RedisConnectionError" } }); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await expect(processAITranslationJob(makeData(), makeContext())).rejects.toThrow( + "Failed to store AI translation result in cache" + ); + }); + + test("stores failure marker on last attempt", async () => { + const aiError = new Error("ai_features_not_enabled"); + mocks.generateOrganizationAIText.mockRejectedValue(aiError); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await expect( + processAITranslationJob(makeData(), makeContext({ attempt: 3, maxAttempts: 3 })) + ).rejects.toThrow(aiError); + + expect(mocks.cacheSet).toHaveBeenCalledWith( + "ai-translation-result:42", + { userId: "user_1", error: "ai_features_not_enabled" }, + 300_000 + ); + }); + + test("does not store failure marker on non-last attempt", async () => { + mocks.generateOrganizationAIText.mockRejectedValue(new Error("transient")); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await expect( + processAITranslationJob(makeData(), makeContext({ attempt: 1, maxAttempts: 3 })) + ).rejects.toThrow("transient"); + + expect(mocks.cacheSet).not.toHaveBeenCalled(); + }); + + test("logs error when failure marker cache.set fails", async () => { + mocks.generateOrganizationAIText.mockRejectedValue(new Error("boom")); + mocks.cacheSet.mockResolvedValue({ ok: false, error: { code: "RedisConnectionError" } }); + + const { processAITranslationJob } = await import("./process-ai-translation-job"); + await expect( + processAITranslationJob(makeData(), makeContext({ attempt: 3, maxAttempts: 3 })) + ).rejects.toThrow("boom"); + + expect(mocks.loggerError).toHaveBeenCalledWith( + { jobId: "42" }, + "Failed to store AI translation failure marker in cache" + ); + }); +}); + +describe("getAITranslationCacheKey", () => { + test("returns prefixed key", async () => { + const { getAITranslationCacheKey } = await import("./process-ai-translation-job"); + expect(getAITranslationCacheKey("99")).toBe("ai-translation-result:99"); + }); +}); diff --git a/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.ts b/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.ts index ae2610f264..c4bc4ebff7 100644 --- a/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.ts +++ b/apps/web/modules/ee/ai-translation/lib/process-ai-translation-job.ts @@ -62,7 +62,14 @@ Rules: } // Store result in Redis for the polling action to pick up - await cache.set(cacheKey, { userId: data.userId, translations }, AI_TRANSLATION_RESULT_TTL_MS); + const cacheResult = await cache.set( + cacheKey, + { userId: data.userId, translations }, + AI_TRANSLATION_RESULT_TTL_MS + ); + if (!cacheResult.ok) { + throw new Error("Failed to store AI translation result in cache"); + } logger.info( { @@ -77,11 +84,14 @@ Rules: // On the last retry attempt, store a failure marker so the client // stops polling instead of spinning until the 2-minute timeout. if (isLastAttempt) { - await cache.set( + const failureResult = await cache.set( cacheKey, { userId: data.userId, error: error instanceof Error ? error.message : "Translation failed" }, AI_TRANSLATION_RESULT_TTL_MS ); + if (!failureResult.ok) { + logger.error({ jobId: context.jobId }, "Failed to store AI translation failure marker in cache"); + } } throw error; } diff --git a/apps/web/modules/survey/multi-language-surveys/components/rich-text-translation-input.tsx b/apps/web/modules/survey/multi-language-surveys/components/rich-text-translation-input.tsx index 5fac8287d9..7378bd0545 100644 --- a/apps/web/modules/survey/multi-language-surveys/components/rich-text-translation-input.tsx +++ b/apps/web/modules/survey/multi-language-surveys/components/rich-text-translation-input.tsx @@ -39,7 +39,7 @@ export const RichTextTranslationInput = ({ }, [disabled]); return ( -
+