diff --git a/.github/workflows/api-v3-contract-tests.yml b/.github/workflows/api-v3-contract-tests.yml new file mode 100644 index 000000000000..cdd0ad40830b --- /dev/null +++ b/.github/workflows/api-v3-contract-tests.yml @@ -0,0 +1,271 @@ +name: API v3 Contract Tests + +# Drives a real instance against the committed v3 OpenAPI bundle with Schemathesis and fails when a +# response violates the documented status codes, content type, or schema — or answers 5xx. +# api-v3-spec.yml proves the document is well-formed and the bundle is fresh; this proves the running +# API still matches it. Harness and local instructions: docs/api-v3-reference/contract-tests/. +# +# Fast profile on purpose: the `examples` phase only, which is a sampled case per documented +# operation (~70 requests, ~25s). The job's cost is the Next.js build, not the tests. Deep fuzzing, +# the coverage phase and stateful suites are deliberately out of the PR gate. + +on: + workflow_call: + secrets: + ENTERPRISE_LICENSE_KEY: + # Optional: without it every entitlement-gated operation answers its documented 403 instead of + # a real payload — the run stays honest, just shallower, which is also the fork-PR path since + # secrets are never available there. With it, `contacts` resolves; `workflows` does not yet, + # because the CI key's feature set does not grant it (ENG-2553). The probe after boot reports + # which of the two you got. + required: false + workflow_dispatch: + +permissions: + contents: read + +# No `concurrency` block on purpose: under `workflow_call`, `github.workflow` resolves to the *caller*, +# so the obvious group expression would collide with pr.yml's own group and let this job cancel its +# parent. The caller's concurrency already covers superseded runs. e2e.yml omits it for the same reason. + +jobs: + contract-tests: + name: v3 OpenAPI contract tests + runs-on: ubuntu-latest + timeout-minutes: 25 + services: + # Same pinned images as e2e.yml so both jobs exercise the same engine versions. + postgres: + image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a + env: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + valkey: + image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d + ports: + - 6379:6379 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false # no later step needs git auth (dangerous-git-checkout re-auths) + - uses: ./.github/actions/dangerous-git-checkout + + # This workflow is resolved from the base branch but tests the PR head, so branches cut before + # this landed do not contain the harness. Detect it and skip instead of failing every open PR — + # same pattern, and same reason, as integration-tests.yml. + - name: Detect contract-test harness + id: harness + shell: bash + run: | + if [ -f docs/api-v3-reference/contract-tests/hooks.py ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::v3 contract-test harness absent on this ref (branch predates ENG-2191) — skipping." + fi + + - name: Install pnpm + if: steps.harness.outputs.present == 'true' + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - name: Setup Node.js (version from .nvmrc) + if: steps.harness.outputs.present == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: Install dependencies + if: steps.harness.outputs.present == 'true' + run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 + shell: bash + + # Must exist before the build: @formbricks/database's prisma.config.ts resolves DATABASE_URL at + # config load, so `prisma generate` fails without it. Same order as e2e.yml. + - name: Create .env + if: steps.harness.outputs.present == 'true' + run: pnpm dev:setup + shell: bash + + - name: Point .env at the CI services + if: steps.harness.outputs.present == 'true' + shell: bash + # The license reaches the script through `env`, never interpolated into the command: a `${{ }}` + # secret pasted into a `sed` expression both breaks on its own metacharacters and is the pattern + # GitHub's hardening guidance tells you not to write. + env: + ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }} + run: | + sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env + sed -i "/^ENTERPRISE_LICENSE_KEY=/d" .env + printf 'ENTERPRISE_LICENSE_KEY=%s\n' "${ENTERPRISE_LICENSE_KEY}" >> .env + # A burst of cases would otherwise trip the limiter and turn most operations into + # documented-but-uninteresting 429s. + echo "RATE_LIMITING_DISABLED=1" >> .env + # Not about running Playwright: this is the flag that stops the app sending an instanceId + # alongside the shared licence key (license.ts — "Skip instance ID during E2E tests to avoid + # license key conflicts"). Without it the licence server answers 403 "bound to another + # instance", every entitlement falls back to DEFAULT_FEATURES, and the 13 workflow + # operations plus contacts are only ever checked against their documented 403. + echo "E2E_TESTING=1" >> .env + + - name: Build App + if: steps.harness.outputs.present == 'true' + timeout-minutes: 15 + env: + # Turborepo hashes its own process environment, not the `.env` file, and next.config.mjs + # reads this at build time — so setting it only in `.env` would let a cached non-E2E build + # be replayed here. Same reason e2e.yml passes it twice. + E2E_TESTING: "1" + run: pnpm build --filter=@formbricks/web... + shell: bash + + - name: Apply Prisma migrations + if: steps.harness.outputs.present == 'true' + # @formbricks/database is already built by the build step, so run the migration runner + # directly rather than db:migrate:dev, which would rebuild and re-generate the package. + run: pnpm --filter=@formbricks/database db:migrate:ci + shell: bash + + - name: Seed the workspace and a throwaway API key + if: steps.harness.outputs.present == 'true' + shell: bash + run: | + # Generated per run and never leaves this job: the database is disposable and the key only + # ever grants access to the seeded workspace on this runner. + SEED_API_KEY="$(openssl rand -hex 32)" + echo "::add-mask::${SEED_API_KEY}" + echo "SEED_API_KEY=${SEED_API_KEY}" >> "$GITHUB_ENV" + SEED_API_KEY="${SEED_API_KEY}" pnpm --filter=@formbricks/database db:seed + + - name: Seed the contract fixtures + if: steps.harness.outputs.present == 'true' + run: pnpm --filter=@formbricks/database db:seed:contract + shell: bash + + - name: Run App + if: steps.harness.outputs.present == 'true' + shell: bash + run: | + NODE_ENV=test pnpm start --filter=@formbricks/web > app.log 2>&1 & + for attempt in {1..20}; do + if [ "$(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/health)" -eq 200 ]; then + echo "Application is ready." + exit 0 + fi + echo "Waiting for the application to be ready... (${attempt}/20)" + sleep 5 + done + echo "::error::Application failed to start in time." + exit 1 + + # Guards the failure mode this job is most likely to die of quietly: if seeding or the API key + # broke, every operation would answer 401/403 — all documented, so Schemathesis would report a + # green run that proved nothing. One authenticated read is enough to know the fixtures are live. + - name: Verify the seeded API key reaches real data + if: steps.harness.outputs.present == 'true' + shell: bash + run: | + workspace_id=$(node -p "require('./docs/api-v3-reference/contract-tests/fixtures.json').workspaceId") + status=$(curl -o /tmp/preflight.json -s -w "%{http_code}" \ + -H "x-api-key: fbk_${SEED_API_KEY}" \ + "http://localhost:3000/api/v3/surveys?workspaceId=${workspace_id}&limit=1") + if [ "${status}" -ne 200 ]; then + echo "::error::Pre-flight read returned ${status}, expected 200. Seeded data or API key is broken — the contract run would be meaningless." + cat /tmp/preflight.json + exit 1 + fi + + # The list endpoint above answers 200 off the base seed alone, so it says nothing about the + # contract fixtures. The deepest response schema in the contract — the survey resource — + # only gets exercised when the read fixture exists *and* carries the languages the spec's + # `lang` examples ask for; without them the operation degrades to a documented 400 and the + # suite stays green having validated nothing. + survey_id=$(node -p "require('./docs/api-v3-reference/contract-tests/fixtures.json').read.surveyId") + survey_status=$(curl -o /tmp/preflight-survey.json -s -w "%{http_code}" \ + -H "x-api-key: fbk_${SEED_API_KEY}" \ + "http://localhost:3000/api/v3/surveys/${survey_id}?lang=de-DE") + if [ "${survey_status}" -ne 200 ]; then + echo "::error::Read fixture check returned ${survey_status}, expected 200. The survey resource schema would not be validated by this run." + cat /tmp/preflight-survey.json + exit 1 + fi + + # Entitlement-gated operations answer a documented 403 when the licence does not grant the + # feature, so the suite stays green while testing half the contract shallowly. Probe one of + # them and say so out loud, rather than leaving that to whoever reads the warnings. + workflows_status=$(curl -o /tmp/preflight-workflows.json -s -w "%{http_code}" \ + -H "x-api-key: fbk_${SEED_API_KEY}" \ + "http://localhost:3000/api/v3/workflows?workspaceId=${workspace_id}&limit=1") + if [ "${workflows_status}" -eq 200 ]; then + echo "::notice::Workflows entitlement active — the 13 workflow operations run against real data." + elif [ "${workflows_status}" -eq 403 ]; then + echo "::warning::Workflows entitlement inactive (GET /api/v3/workflows → ${workflows_status}). The 13 workflow operations are only checked against their documented 403." + cat /tmp/preflight-workflows.json + else + # Anything else is a setup failure, not an entitlement answer. Treating it as "inactive" + # would let a 401 or 404 pass as a warning and the run go green having never exercised the + # workflow fixtures at all — the shallow-green outcome this whole job exists to prevent. + echo "::error::Workflow entitlement probe returned ${workflows_status}, expected 200 or 403." + cat /tmp/preflight-workflows.json + exit 1 + fi + + - name: Set up Python + if: steps.harness.outputs.present == 'true' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Install Schemathesis + if: steps.harness.outputs.present == 'true' + run: python -m pip install schemathesis==4.25.0 + shell: bash + + - name: Run v3 contract tests + if: steps.harness.outputs.present == 'true' + shell: bash + env: + SCHEMATHESIS_HOOKS: docs/api-v3-reference/contract-tests/hooks.py + run: | + schemathesis \ + --config-file docs/api-v3-reference/contract-tests/schemathesis.toml \ + run docs/api-v3-reference/openapi.yml \ + --url http://localhost:3000 \ + --phases examples \ + --checks not_a_server_error,status_code_conformance,content_type_conformance,response_schema_conformance \ + --generation-database=:memory: \ + --report junit \ + --report-dir schemathesis-report \ + -H "x-api-key: fbk_${SEED_API_KEY}" + + - name: Upload contract test report + if: always() && steps.harness.outputs.present == 'true' + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: api-v3-contract-report + if-no-files-found: ignore + path: schemathesis-report/ + + # Failure only. A green run's log has nothing to say that the entitlement probe above does not, + # and an application log is the wrong thing to ship as an artifact on every green PR. + - name: Upload app logs + if: failure() && steps.harness.outputs.present == 'true' + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: api-v3-contract-app-logs + if-no-files-found: ignore + path: app.log diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bd6f3f9db98c..c9d283e4467f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -53,9 +53,29 @@ jobs: uses: ./.github/workflows/e2e.yml secrets: inherit + # No `paths:` filter, deliberately: `required` below fails on a skipped job, and a required check + # that never reports would keep every PR out of the merge queue. It runs concurrently with e2e-test, + # which is the gate's critical path, so it costs runner minutes rather than wall-clock. + api-v3-contract-tests: + name: Run API v3 Contract Tests + uses: ./.github/workflows/api-v3-contract-tests.yml + # Named rather than `inherit`: this job builds and runs PR-head code, and the licence is the only + # secret it declares. Inheriting would hand it every other repository and organisation secret too. + secrets: + ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }} + required: name: PR Check Summary - needs: [lint, typecheck, test, helm-chart-validation, coderabbit-config-validation, e2e-test] + needs: + [ + lint, + typecheck, + test, + helm-chart-validation, + coderabbit-config-validation, + e2e-test, + api-v3-contract-tests, + ] if: always() runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 08c7c24998a3..8c7401da8841 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,9 @@ stats.html .claude/skills/* !.claude/skills/nextjs-docs .agents/formbricks-context + +# v3 API contract tests: generated fixture id map, Schemathesis' local replay cache, and the +# bytecode cache Python leaves next to hooks.py +docs/api-v3-reference/contract-tests/fixtures.json +.schemathesis/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 75eb3c2c1775..9a0caa555add 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,6 +230,12 @@ Do: evaluators, calculations, and edge cases. Keep assertions on inputs and outputs, colocate specs with the code they exercise (`utility.test.ts`), and mock network and storage boundaries through helpers from `@formbricks/*`. +- API v3 contract tests (Schemathesis): every documented `/api/v3` operation is driven against a real + instance on each PR and must match the committed OpenAPI bundle — status codes, content type and + response schema. Nothing to register per endpoint; documenting an operation is what enrolls it. To + exercise a new one against real data rather than its documented 403, add the resource in + `packages/database/src/scripts/seed-contract-fixtures.ts`. Harness and local run: + `docs/api-v3-reference/contract-tests/README.md`. - Manual QA, especially for releases: verify on staging and file bugs. If a bug is critical, backport and re-test. For UI detail below the journey level, manual verification plus a screenshot in the PR is the expected answer, not a new spec. diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index aa9d997acd8f..d368706808c5 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2939,7 +2939,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: 098a2293b39f9f258d67f926cf03df37 + workspace/surveys/edit/ai_translating: 5f60cc47ce106b98251c46197e4981c0 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 5f11e13c2b13..96bcba96740a 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -3056,7 +3056,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... Bitte lasse dieses Fenster geöffnet.", + "ai_translating": "Übersetze mit KI... Bitte lass 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 9dde412d99cd..9125715bd1ab 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -3056,7 +3056,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... Please keep this modal open.", + "ai_translating": "Translating with AI... Please keep this window 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 28ca5c1bb5bd..fe00b690c3ce 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -3056,7 +3056,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... Por favor, mantén este modal abierto.", + "ai_translating": "Traduciendo con IA... Mantén esta ventana abierta.", "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 da0ab98fa758..1397baf4a6c5 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -3056,7 +3056,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 en cours avec l'IA... Garde cette fenêtre ouverte.", + "ai_translating": "Traduction avec l'IA en cours... Merci de garder 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 4a5fae6840c0..af6e274ad667 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -3056,7 +3056,7 @@ "ai_instance_not_configured": "Az MI nincs beállítva. Vegye fel a kapcsolatot az adminisztrátorral.", "ai_smart_tools_disabled": "Az MI intelligens eszközei le vannak tiltva ennél a szervezetnél.", "ai_translate": "Fordítás MI-vel", - "ai_translating": "Fordítás MI-vel… Tartsa nyitva ezt a párbeszédablakot.", + "ai_translating": "AI fordítás folyamatban... Kérem, tartsa nyitva ezt az ablakot.", "ai_translation_all_fields_populated": "Az összes mező le van már fordítva", "ai_translation_complete": "Az MI-fordítás befejeződött", "ai_translation_failed": "A fordítás nem sikerült", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 09aa1501dff5..d22b1806a8e8 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -3056,7 +3056,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 2afbe568274c..1b15322ba184 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -3056,7 +3056,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": "Vertalen met AI... Laat dit venster open.", + "ai_translating": "Vertalen met AI... Houd 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 8e5625d4106e..7ecba507043b 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -3056,7 +3056,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... Por favor, mantenha este modal aberto.", + "ai_translating": "Traduzindo com IA... Mantenha esta janela aberta.", "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 7a9d3a7a9d5e..45e83d537848 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -3056,7 +3056,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... Mantém esta janela aberta, por favor.", + "ai_translating": "A traduzir com IA... Mantém esta janela aberta.", "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 11555a70f1c5..b842224325fa 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -3056,7 +3056,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... Te rugăm să ții această fereastră deschisă.", + "ai_translating": "Traducere cu AI... Te rugăm să păstrezi 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 300a05e70ebe..5a0b4b2d5d1f 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -3056,7 +3056,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 21382decfe3b..96a0d297e672 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -3056,7 +3056,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... Vänligen håll denna dialogruta öppen.", + "ai_translating": "Översätter med AI... Håll det här fönstret öppet.", "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 c2ab5dad463b..e192dd58ce30 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -3056,7 +3056,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... Lütfen bu pencereyi açık tutun.", + "ai_translating": "Yapay zeka ile çevriliyor... Lütfen bu pencereyi açık tut.", "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 0d225d1b5618..658f4f60b969 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -3056,7 +3056,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 5ec514c993bb..aef47f96cc88 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -3056,7 +3056,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/survey/multi-language-surveys/components/manage-translations-modal.tsx b/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx index adb7914a36e5..687cb26d3e08 100644 --- a/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx +++ b/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx @@ -219,7 +219,7 @@ export const ManageTranslationsModal = ({ return ( - + {t("workspace.surveys.edit.manage_translations")}
diff --git a/docs/api-v3-reference/contract-tests/README.md b/docs/api-v3-reference/contract-tests/README.md new file mode 100644 index 000000000000..51852f41d225 --- /dev/null +++ b/docs/api-v3-reference/contract-tests/README.md @@ -0,0 +1,69 @@ +# v3 API contract tests + +[Schemathesis](https://schemathesis.readthedocs.io/) drives a running instance against +[`../openapi.yml`](../openapi.yml) — the committed bundle — and fails when a response violates the +documented status codes, content type, or schema, or when anything answers 5xx. It runs on every PR +via [`.github/workflows/api-v3-contract-tests.yml`](../../../.github/workflows/api-v3-contract-tests.yml) +and is part of the `PR Check Summary` required check. + +`api-v3-spec.yml` already lints the source tree and fails on a stale bundle. That proves the +document is well-formed and current; this proves the running API still matches it. + +## What is in here + +| File | Purpose | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `schemathesis.toml` | Phase/timeout config. `[phases.examples] fill-missing` is what makes all 28 operations run instead of the 3 that carry explicit examples. | +| `hooks.py` | Registers the `cuid2` format and substitutes seeded identifiers, keyed by parameter name and operationId. | +| `fixtures.json` | Generated by `db:seed:contract`, gitignored. The id map `hooks.py` reads. | + +Coverage is automatic: a newly documented operation is exercised as soon as it is in the bundle. If +its identifiers are not in `fixtures.json` it runs against a generated cuid2 and gets the documented +403 — still a real contract check, just a shallower one. To exercise it against real data, add the +resource in `packages/database/src/scripts/seed-contract-fixtures.ts`. + +## Running it locally + +Against a dev stack (`pnpm db:up`, app on `localhost:3000`): + +```bash +# 1. Seed the base data plus an API key. Any throwaway secret works; nothing is committed. +export SEED_API_KEY="$(openssl rand -hex 32)" +pnpm --filter=@formbricks/database db:seed +pnpm --filter=@formbricks/database db:seed:contract + +# 2. Run the suite (uv keeps the pinned version out of your global site-packages). +SCHEMATHESIS_HOOKS=docs/api-v3-reference/contract-tests/hooks.py \ + uvx --from 'schemathesis==4.25.0' schemathesis \ + --config-file docs/api-v3-reference/contract-tests/schemathesis.toml \ + run docs/api-v3-reference/openapi.yml \ + --url http://localhost:3000 \ + --phases examples \ + --checks not_a_server_error,status_code_conformance,content_type_conformance,response_schema_conformance \ + -H "x-api-key: fbk_${SEED_API_KEY}" +``` + +Three things to know before reading the result: + +- **Re-run `db:seed:contract` between runs.** The mutating operations consume their fixtures — the + survey `DELETE` deletes one, `enable` flips another out of `draft`. CI always starts from an empty + database, so it only matters locally: a second run without re-seeding turns those operations into + documented 403s and quietly loses depth. + +- **Workflow endpoints need an enterprise license.** Thirteen of the twenty-eight operations (every + `/workflows` path, the two `/workflows/runs` reads included — they all authorize through + `buildWorkflowApiContext`), plus `contact-attribute-keys`, are entitlement-gated. Without + `ENTERPRISE_LICENSE_KEY` they answer a documented 403, so the run stays green but tests those + operations shallowly. A licence is necessary but not sufficient: it also has to _grant_ the + feature. CI's key grants `contacts` and not `workflows` (ENG-2553), so the 13 workflow operations + are shallow there too — the job's post-boot probe says which state you are in on every run. +- **The four `/tags` operations are session-only** (`auth: "session"`, and the spec declares only + `sessionAuth`), so an API key gets a documented 401 before any handler runs. They are checked + against that 401, which is a real assertion that they stay session-only — but no tag fixtures + exist, because nothing an API key sends can reach them. +- **Rate limiting.** Set `RATE_LIMITING_DISABLED=1`, otherwise a burst of cases can turn into + documented-but-uninteresting 429s. + +For deeper local exploration, drop `--phases examples` for `--phases coverage` (boundary values) or +`--phases fuzzing -n 50`. Both are far slower than the PR budget allows, which is why CI runs +examples only. diff --git a/docs/api-v3-reference/contract-tests/hooks.py b/docs/api-v3-reference/contract-tests/hooks.py new file mode 100644 index 000000000000..4648fad127f2 --- /dev/null +++ b/docs/api-v3-reference/contract-tests/hooks.py @@ -0,0 +1,125 @@ +"""Schemathesis extensions for the v3 API contract tests (ENG-2191). + +Two jobs, both generic over the contract rather than per endpoint: + +1. Teach Schemathesis the ``cuid2`` string format every v3 identifier declares. Without it an + unknown format degrades to an arbitrary string — including values like ``""`` or ``"0"`` that + route somewhere other than the operation under test, producing failures that say nothing about + the contract. + +2. Point identifiers at the seeded data so authenticated endpoints answer with real payloads + instead of a wall of 401/403s. Substitution is keyed by parameter name and operationId, from the + map ``db:seed:contract`` writes (``fixtures.json``) — nothing here is registered per test, so a + newly documented endpoint is exercised the moment it lands in the bundle. An id the map does not + mention keeps its generated cuid2 and gets the documented 403, which is still a contract check. + +Everything runs in ``before_call`` because that is the one place a value always wins: cases derived +from the spec's own examples never reach ``map_query`` / ``map_path_parameters`` with a populated +dict, so overriding there would silently skip exactly the operations that carry examples. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import schemathesis +from hypothesis import strategies as st + +FIXTURES_ENV_VAR = "V3_CONTRACT_FIXTURES" +DEFAULT_FIXTURES_PATH = Path(__file__).with_name("fixtures.json") + +# cuid2 as the v3 routes validate it (`z.cuid2()` in packages/types/common.ts): lowercase +# alphanumeric. The fixed length keeps generated ids visually distinct from real ones in reports. +CUID2_STRATEGY = st.from_regex(r"\A[a-z][a-z0-9]{23}\Z") + +# Any parameter or body field with this name identifies the tenant, on every current and future v3 +# operation, so it is substituted globally rather than per operation. +WORKSPACE_FIELD = "workspaceId" + + +def _load_fixtures() -> dict[str, Any]: + path = Path(os.environ.get(FIXTURES_ENV_VAR) or DEFAULT_FIXTURES_PATH) + # `ValueError` rather than `json.JSONDecodeError` alone: a truncated write, a hand-edit, and a file + # that is not UTF-8 all reach the reader as the same problem as a missing one — "your fixtures are + # not usable, re-seed" — and `UnicodeDecodeError` is a ValueError that JSONDecodeError misses. + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RuntimeError( + f"Contract fixtures at {path} are missing or unreadable. Run " + "`pnpm --filter=@formbricks/database db:seed:contract` first, or point " + f"{FIXTURES_ENV_VAR} at the file it wrote. Running without it would test an empty " + "workspace and report a green run that proves nothing." + ) from exc + + +_FIXTURES = _load_fixtures() +# Explicit rather than `_FIXTURES["workspaceId"]`: a map written by an older revision of the seed raises +# a bare KeyError out of hook import, which reaches the developer as an opaque "failed to load +# SCHEMATHESIS_HOOKS" instead of the one instruction that fixes it. +if not _FIXTURES.get(WORKSPACE_FIELD): + raise RuntimeError( + f"Contract fixtures at {os.environ.get(FIXTURES_ENV_VAR) or DEFAULT_FIXTURES_PATH} carry no " + f"'{WORKSPACE_FIELD}'. Re-run `pnpm --filter=@formbricks/database db:seed:contract` to " + "regenerate them. Running without it would test an empty workspace and report a green run " + "that proves nothing." + ) +WORKSPACE_ID: str = _FIXTURES[WORKSPACE_FIELD] +# Defaults keyed by parameter name, used by every operation without a more specific entry. +READ_IDS: dict[str, str] = _FIXTURES.get("read", {}) +# Per-operationId overrides: {"deleteSurveyV3": {"path": {...}, "body": {...}}}. Mutating operations +# get their own disposable resource here so a DELETE cannot decide what a GET elsewhere sees. +OPERATION_IDS: dict[str, dict[str, dict[str, str]]] = _FIXTURES.get("operations", {}) + +schemathesis.openapi.format("cuid2", CUID2_STRATEGY) + + +def _operation_id(case: Any) -> str | None: + definition = getattr(case.operation, "definition", None) + raw = getattr(definition, "raw", None) + return raw.get("operationId") if isinstance(raw, dict) else None + + +def _substitute(container: Any, overrides: dict[str, str], defaults: dict[str, str]) -> None: + """Replace known identifiers in place, leaving unknown fields to the generated data. + + Recursive because identifiers are not always top level: `POST /api/v3/surveys/validate` carries + the workspace under `data.workspaceId`, and a workflow definition references its trigger survey + from inside `definition.trigger.config`. Every name handled here means the same thing wherever it + appears in this API, so descending is safe. + """ + if isinstance(container, list): + for item in container: + _substitute(item, overrides, defaults) + return + + if not isinstance(container, dict): + return + + for name, value in container.items(): + # Per-operation overrides come first, including for `workspaceId`: an operation that wants a + # foreign workspace — to assert the documented cross-tenant 403 — has to be able to say so, + # and `operations` is the map documented as winning over the defaults. + if name in overrides: + container[name] = overrides[name] + elif name == WORKSPACE_FIELD: + container[name] = WORKSPACE_ID + elif name in defaults: + container[name] = defaults[name] + else: + _substitute(value, overrides, defaults) + + +@schemathesis.hook +def before_call(ctx: Any, case: Any, transport_kwargs: dict[str, Any]) -> None: # noqa: ARG001 + operation = OPERATION_IDS.get(_operation_id(case) or "", {}) + + _substitute(case.path_parameters, operation.get("path", {}), READ_IDS) + _substitute(case.query, operation.get("query", {}), READ_IDS) + # Body identifiers are substituted too: `POST /api/v3/surveys` carries its workspaceId in the + # body, and `POST /api/v3/tags/{tagId}/merge` names the surviving tag there. Read defaults apply + # so a generated workflow definition points its trigger at a survey that exists. + _substitute(case.body, operation.get("body", {}), READ_IDS) diff --git a/docs/api-v3-reference/contract-tests/schemathesis.toml b/docs/api-v3-reference/contract-tests/schemathesis.toml new file mode 100644 index 000000000000..aa0a5a0a9245 --- /dev/null +++ b/docs/api-v3-reference/contract-tests/schemathesis.toml @@ -0,0 +1,24 @@ +# Schemathesis configuration for the v3 API contract tests (ENG-2191). +# +# The committed bundle one directory up (../openapi.yml) is the only contract input — there is no +# second copy of the spec to keep in sync. Pass this file explicitly, since Schemathesis only +# discovers `schemathesis.toml` in the working directory and its parents: +# +# schemathesis --config-file docs/api-v3-reference/contract-tests/schemathesis.toml run ... +# +# See README.md in this directory for the full local invocation. + +# Overridden by --url in CI; this is the local dev-server default. +base-url = "http://localhost:3000" + +# Generous enough for a cold Next.js route on the first hit, tight enough that a hung route fails the +# job instead of sitting until the step timeout. +request-timeout = 30 + +[phases.examples] +# Load-bearing. The examples phase only emits cases for operations that carry explicit `example` / +# `examples` in the spec — three of the twenty-eight v3 operations today. The other twenty-five are +# silently skipped, which reads as a green run that proves nothing. `fill-missing` generates the +# absent parts so every documented operation gets a case, which is also what makes newly documented +# endpoints covered with no per-endpoint registration. +fill-missing = true diff --git a/docs/images/surveys/general-features/multi-language-surveys/add-language-in-survey.webp b/docs/images/surveys/general-features/multi-language-surveys/add-language-in-survey.webp deleted file mode 100644 index 7e493802ef12..000000000000 Binary files a/docs/images/surveys/general-features/multi-language-surveys/add-language-in-survey.webp and /dev/null differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/add-languages.webp b/docs/images/surveys/general-features/multi-language-surveys/add-languages.webp index a9d5def38c10..4dc175f93d63 100644 Binary files a/docs/images/surveys/general-features/multi-language-surveys/add-languages.webp and b/docs/images/surveys/general-features/multi-language-surveys/add-languages.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/enable-multi-lang.webp b/docs/images/surveys/general-features/multi-language-surveys/enable-multi-lang.webp deleted file mode 100644 index ff26f0a1909c..000000000000 Binary files a/docs/images/surveys/general-features/multi-language-surveys/enable-multi-lang.webp and /dev/null differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/language-switch.webp b/docs/images/surveys/general-features/multi-language-surveys/language-switch.webp new file mode 100644 index 000000000000..741c5b2ca16e Binary files /dev/null and b/docs/images/surveys/general-features/multi-language-surveys/language-switch.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/language-tab.webp b/docs/images/surveys/general-features/multi-language-surveys/language-tab.webp new file mode 100644 index 000000000000..31195ec242c4 Binary files /dev/null and b/docs/images/surveys/general-features/multi-language-surveys/language-tab.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/manage-translations.webp b/docs/images/surveys/general-features/multi-language-surveys/manage-translations.webp new file mode 100644 index 000000000000..f1993361783d Binary files /dev/null and b/docs/images/surveys/general-features/multi-language-surveys/manage-translations.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/rtl-support.webp b/docs/images/surveys/general-features/multi-language-surveys/rtl-support.webp index 446f1a209603..c329bdbed124 100644 Binary files a/docs/images/surveys/general-features/multi-language-surveys/rtl-support.webp and b/docs/images/surveys/general-features/multi-language-surveys/rtl-support.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/share-link-language.webp b/docs/images/surveys/general-features/multi-language-surveys/share-link-language.webp new file mode 100644 index 000000000000..b1d7508e1588 Binary files /dev/null and b/docs/images/surveys/general-features/multi-language-surveys/share-link-language.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/survey-languages-settings.webp b/docs/images/surveys/general-features/multi-language-surveys/survey-languages-settings.webp new file mode 100644 index 000000000000..ea60e3cfa5a4 Binary files /dev/null and b/docs/images/surveys/general-features/multi-language-surveys/survey-languages-settings.webp differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/surveys-home.webp b/docs/images/surveys/general-features/multi-language-surveys/surveys-home.webp deleted file mode 100644 index c07aa6f271eb..000000000000 Binary files a/docs/images/surveys/general-features/multi-language-surveys/surveys-home.webp and /dev/null differ diff --git a/docs/images/surveys/general-features/multi-language-surveys/workspace-configuration.webp b/docs/images/surveys/general-features/multi-language-surveys/workspace-configuration.webp deleted file mode 100644 index 8ebe4d830057..000000000000 Binary files a/docs/images/surveys/general-features/multi-language-surveys/workspace-configuration.webp and /dev/null differ diff --git a/docs/surveys/general-features/multi-language-surveys.mdx b/docs/surveys/general-features/multi-language-surveys.mdx index a5f43afcc1dc..4e9e83eccd95 100644 --- a/docs/surveys/general-features/multi-language-surveys.mdx +++ b/docs/surveys/general-features/multi-language-surveys.mdx @@ -1,216 +1,294 @@ --- title: "Multi-language Surveys" -description: "Create surveys that support multiple languages using translations. This helps you reach a diverse audience without making separate surveys for each language. It also simplifies survey creation, delivery, and analysis for multilingual audiences." +description: "Run one survey in many languages: translate it once — by hand or with one click — and every response lands in the same dataset with the language it was answered in." icon: "language" --- -How to deliver a specific language depends on the survey type (app or link survey): +A multi-language survey is one survey with one set of questions, logic and results, carrying a translation +for every language you support. You do not duplicate the survey per language, and you do not split your +results across several summaries. -- App & Website survey: Set a `language` attribute for the user. [Read this guide for App Surveys](#app-surveys-configuration) +How a respondent ends up in a given language depends on the survey type: -- Link survey: Add a `lang` parameter in the survey URL. [Read this guide for Link Surveys](#link-surveys-configuration) +| Survey type | How the language is chosen | If the translation is missing | +| --- | --- | --- | +| Link survey | The `lang` URL parameter, or the respondent picks it in the language switch | Falls back to the survey's default language | +| App & website survey | The `language` attribute you set through the SDK with `setLanguage()` | The survey is **not shown** to that user | + +That difference is deliberate: a link is a public URL that must always render something, while an app survey +is targeted at a known user, and showing them a survey in a language they did not ask for is worse than not +showing it at all. See [App and website surveys](#app-and-website-surveys) for the full rule. --- -## Creating a Multi-language Survey +## Add languages to your workspace - - - Go to Configuration and open the **Survey Languages tab**: +Languages live on the workspace, so every survey in it can use the same list. - ![Survey Configuration](/images/surveys/general-features/multi-language-surveys/workspace-configuration.webp) + + + Go to your workspace settings and open **Survey Languages**: - Click on the **Edit languages** button to add a new language to your survey. + ![Survey Languages settings in the workspace](/images/surveys/general-features/multi-language-surveys/survey-languages-settings.webp) + - Select the preferred language from the dropdown and assign an identifier Alias. Click the **Add language** button to add the language to your Workspace: + + Click **Edit languages**, then **Add language**, and pick the language from the dropdown. The + **Identifier** is filled in for you — it is the language's BCP-47 tag, such as `de-DE` or `ar-EG`. - ![Add Multiple Languages to your Workspace](/images/surveys/general-features/multi-language-surveys/add-languages.webp) + ![Adding a language to the workspace](/images/surveys/general-features/multi-language-surveys/add-languages.webp) - You can come back to this page anytime to add more languages or remove existing ones. + Optionally set an **Alias**: an alternate name you can use instead of the identifier in link survey URLs + and in the SDK. It is worth setting when your app already works in tags of its own, such as `en_us`. A + bare two-letter code like `de` is rejected — it names a language of its own in the picker, so allowing it + as another language's alias would make the two ambiguous. Click **Save changes** when you are done. + + + + The language itself and its identifier are fixed once the language is created — only the alias stays + editable. A language that is still used by a survey cannot be removed; Formbricks lists the surveys that + hold it so you can remove it there first. + - - Return to the dashboard to create a new survey or edit an existing one: +--- - ![Survey Overview](/images/surveys/general-features/multi-language-surveys/surveys-home.webp) - +## Activate translations for a survey - - In the survey editor, scroll down to the **Multiple Languages** section at the bottom and enable the toggle next to it: + + + Open the survey editor and switch to the **Language** tab. + - ![Enable Multi-language for a survey](/images/surveys/general-features/multi-language-surveys/enable-multi-lang.webp) + + Enable **Activate translations** and pick the survey's **default language**. The default language is the + one your existing content is already written in. - Choose a **Default Language** for your survey. + ![The Language tab of the survey editor](/images/surveys/general-features/multi-language-surveys/language-tab.webp) - Changing the default language will reset all the translations you have made for the survey. + + Choosing the default language is a one-way decision. To change it later you have to switch + **Activate translations** off, which deletes every translation on the survey. + - - Add the languages from the dropdown that you want to support in your survey: + + Every workspace language shows up as a row with its **Code**, a **Translated** progress bar, and a + **Visible** toggle. Turn **Visible** on for each language respondents should be able to answer in. - ![Add Supported Languages](/images/surveys/general-features/multi-language-surveys/add-language-in-survey.webp) + A visible language must carry a translation for every required field before the survey can be + **published**, and before further changes to an already-published survey can be saved. A draft saves + either way, so you can stop mid-translation and come back. If a live survey blocks you, switch the + unfinished language back to invisible — the translations you already entered are kept. + + +The **Translated** bar counts every translatable string on the survey — headlines, descriptions, choices, +placeholders, button labels and ending cards. While a visible language is incomplete, the **Language** tab +carries an amber warning icon so you can see it from any other tab. + +--- + +## Translate your survey content + +Once a language is switched on, click its row — or **⋮ → Manage translations** — to open the translations +editor for that language. It lists every translatable string side by side: the default language on the left, +the target language on the right. + +![The Manage translations modal with the Translate with AI button](/images/surveys/general-features/multi-language-surveys/manage-translations.webp) - +### Translate with AI - You can now see the survey in the selected language by clicking on the language dropdown in any of the questions. - - Now you can translate all survey content, including questions, options, and button placeholders, into the selected language. +**Translate with AI** fills in every empty field for the language you have open, in one pass — headlines, +descriptions, choices, button labels and all. Strings you have already translated are left untouched, so you +can run it again after adding a question and it will only fill the gap. + + + Open **Manage translations** for that language. The button is active as long as there is at least one + empty field left to fill. + + + + The translations appear in the right-hand column, formatting and rich text included. - - Once you are done, click on the **Publish** button to save the survey. + + Read the result before you keep it — tone and product terms are the two things worth a second look — edit + anything you want to change, and click **Save**. That hands the translations back to the editor; they are + only stored once you save or publish the survey itself. ---- + + **Translate with AI** is an [Enterprise feature](/self-hosting/advanced/license). It needs **Smart + functionality (AI)** enabled for your organization and a configured AI instance — see + [AI Features](/platform/features/ai-features). Without it, the button is disabled and its tooltip says + which of the two is missing. + -## Built-in Interface Translations +### Translate by hand -Beyond the content you translate yourself, every survey ships with a set of built-in interface strings that Formbricks localizes automatically — so respondents see them in their own language without any extra work from you. These include: +Type your translations straight into the right-hand column of the same editor. Two things help on a long +survey: -- Default navigation and action buttons (**Back**, **Next**, **Finish**) -- Form validation messages (e.g. "Please fill out this field", "Please enter a valid email address") -- File-upload prompts and states -- Offline, retry, and "sending responses" notices -- Attribution and helper labels like "Powered by" and "Required" +- **Missing first** re-sorts the list so untranslated strings come first. **Show in order** puts it back into + survey order. +- The **ID** column tells you where a string lives — `1.1` is the first element of the first block, `W` is the + welcome card — so you can find it again in the editor. -These built-in strings are currently provided in **23 languages**: +--- -| Language | Code | Language | Code | -| --- | --- | --- | --- | -| English (base) | `en-US` | Italian | `it-IT` | -| Arabic | `ar-EG` | Japanese | `ja-JP` | -| Chinese (Simplified) | `zh-Hans-CN` | Portuguese (Brazil) | `pt-BR` | -| Chinese (Traditional) | `zh-Hant-TW` | Romanian | `ro-RO` | -| Danish | `da-DK` | Russian | `ru-RU` | -| Dutch | `nl-NL` | Spanish | `es-ES` | -| Estonian | `et-EE` | Swedish | `sv-SE` | -| French | `fr-FR` | Turkish | `tr-TR` | -| German | `de-DE` | Urdu | `ur-PK` | -| Hindi | `hi-IN` | Uzbek | `uz-UZ` | -| Hungarian | `hu-HU` | Vietnamese | `vi-VN` | -| Indonesian | `id-ID` | | | +## Let respondents switch language -Formbricks matches the survey's active language to the closest available bundle — for example, `de-AT` and `de` both use the German bundle, and `pt-PT` uses `pt-BR`. Writing script is preserved when matching, so `zh-Hant` and `zh-TW` resolve to the Traditional Chinese bundle rather than the Simplified one. If a language has no matching bundle, your translated survey content is still shown as usual, but these built-in interface strings fall back to English. +Turn on **Show language switch** in the Language tab to put a language picker in the survey itself, on every +card, so respondents can change language at any point instead of only at the start. It needs at least two +visible languages. - - Don't see your language? These interface translations live in the open-source `@formbricks/surveys` package. You can add a new one by contributing a locale file on [GitHub](https://github.com/formbricks/formbricks). - +![The language switch in a running survey](/images/surveys/general-features/multi-language-surveys/language-switch.webp) + +Languages are listed by their own name — *Deutsch*, *العربية* — rather than translated into the current +language, so a respondent can find their own language even if they cannot read the one on screen. --- -## App Surveys Configuration +## Link surveys - - - After you setup the Formbricks SDK for your user, you can call the `setLanguage` function with the language code. This can be either the ISO identifier or the Alias you set when creating the language. The `language` attribute makes sure that this user only sees surveys with a translation in this specific language available. +A link survey renders in whatever language the `lang` URL parameter asks for: - ```js - Formbricks.setup({ - workspaceId: "", - appUrl: "", // use PUBLIC_URL if you are using multi-domain setup, otherwise use WEBAPP_URL - }); +``` +https://app.formbricks.com/s/?lang=de +``` - Formbricks.setLanguage("de"); // ISO identifier or Alias set when creating language - ``` +You do not have to build that URL by hand. Open **Share survey** and use the language picker next to the +survey URL — Formbricks appends the parameter for you, so you can copy one link per language: - - If a user has a language assigned, a survey has multi-language activated and it is missing a translation in - the language of the user, the survey will not be displayed. - - +![Choosing a language for the survey link in the share modal](/images/surveys/general-features/multi-language-surveys/share-link-language.webp) - - That's it! Now, users with the language attribute set will see the survey in their preferred language. You can start collecting responses in multiple languages and filter them by language on the summary page. - - +`lang` accepts the language's identifier (`de-DE`) or its alias, and matching is case-insensitive. Formbricks +resolves it in a fixed order: an exact identifier first, then an alias, then a canonical match. That last pass +is what makes a shortened or legacy code work — `?lang=de` reaches `de-DE`, and an old `?lang=pt` link still +reaches a language since stored as `pt-BR`. + +Without a `lang` parameter — or with one that names a language the survey does not have, or one that is not +visible — the survey opens in its default language. + + + In **Share survey → Link settings** you can also set the link preview title and description per language, + so a survey shared in a German-speaking channel previews in German. The preview image is one image for the + whole survey — it is not per-language. + --- -## Link Surveys Configuration +## App and website surveys -For link surveys, the translation delivery is dependent on the `lang` URL parameter. +For app and website surveys, the language comes from the user's `language` attribute, which you set through +the SDK: - - - After publishing the survey, just copy the survey link and append the `lang` query parameter with the language alias you have set. - +```js +Formbricks.setup({ + workspaceId: "", + appUrl: "", // use PUBLIC_URL if you are using multi-domain setup, otherwise use WEBAPP_URL +}); - - For example, if you have set the alias for French as `fr`, you can share the survey link as +Formbricks.setLanguage("de-DE"); // identifier or alias of the language +``` - [`https://your-survey-url.com?lang=fr`](https://your-survey-url.com?lang=fr) +Call `setLanguage()` whenever your app's language changes — for example right after the user switches it in +your own settings — and Formbricks will use the new value for every survey it shows from then on. - Here are two examples: +`setLanguage()` accepts a language's identifier or its alias, case-insensitively. Unlike the `lang` URL +parameter, it does not fall back to a canonical match — see the note below. - - English: [https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=en](https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=en) +### What happens when a translation is missing - - German: [https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=de](https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=de) +For a survey with more than one language, Formbricks resolves the user's `language` attribute like this: - Without the `lang` parameter, Formbricks will show the survey in the default language you have set. - +| The user's `language` attribute | What the user sees | +| --- | --- | +| Not set | The survey in its default language | +| Matches the survey's default language | The survey in its default language | +| Matches a visible language on the survey | The survey in that language | +| Matches nothing on the survey, or a language that is not visible | **Nothing — the survey is skipped** | - - You can now start collecting responses in multiple languages! - - +The last row is deliberate: a targeted survey is skipped rather than shown in a language the user did not ask +for. A survey with only one language, or with translations switched off entirely, is exempt — it ignores the +`language` attribute and is shown to a matching user whatever their language is set to. + + + The value you pass has to match a language's identifier or its alias exactly — Formbricks does not widen a + bare code to a region-tagged one here, the way the `lang` parameter does. With a survey language stored as + `ar-EG`, `setLanguage("ar")` matches nothing and the survey is skipped, while `?lang=ar` on a link survey + still resolves. Pass the identifier exactly as **Survey Languages** shows it; a bare code cannot be papered + over with an alias, because `ar` is rejected as one. + --- -## Translate with AI +## Right-to-left languages -Translating every question, option, and label by hand can take a while. If your organization has AI enabled, you can fill in missing translations in one click. +Right-to-left languages need no configuration. When the active language is an RTL one — Arabic, Hebrew, +Persian or Urdu — the whole survey mirrors itself: text alignment, progress bar, rating scales, button +placement and the language switch. - - - Inside the survey editor, switch to the language you want to translate into and open the **Manage Translations** modal. - +![A survey rendered in Arabic, right to left](/images/surveys/general-features/multi-language-surveys/rtl-support.webp) - - The button is enabled when there are empty fields in the selected target language. Formbricks translates all empty headlines, descriptions, choices, and button labels from the default language into the target language. - +--- - - AI-translated strings are filled into the editor like manual translations. Review them before publishing and tweak anything that needs a different tone or wording. - - +## Built-in interface translations - - AI translation is an [Enterprise feature](/self-hosting/advanced/license) and requires **Smart functionality (AI)** to be enabled at the organization level. See [AI Features](/platform/features/ai-features). - +Beyond the content you translate yourself, every survey ships with a set of built-in interface strings that +Formbricks localizes automatically — so respondents see them in their own language without any extra work +from you. These include: ---- +- Default navigation and action buttons (**Back**, **Next**, **Finish**) +- Form validation messages (e.g. "Please fill out this field", "Please enter a valid email address") +- File-upload prompts and states +- Offline, retry, and "sending responses" notices +- Attribution and helper labels like "Powered by" and "Required" -## RTL Language Support +These built-in strings are currently provided in **23 languages**: -Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Persian, and Urdu. When you add an RTL language to your survey, the survey interface automatically adjusts to display content from right to left. +| Language | Code | Language | Code | +| --- | --- | --- | --- | +| English (base) | `en-US` | Italian | `it-IT` | +| Arabic | `ar-EG` | Japanese | `ja-JP` | +| Chinese (Simplified) | `zh-Hans-CN` | Portuguese (Brazil) | `pt-BR` | +| Chinese (Traditional) | `zh-Hant-TW` | Romanian | `ro-RO` | +| Danish | `da-DK` | Russian | `ru-RU` | +| Dutch | `nl-NL` | Spanish | `es-ES` | +| Estonian | `et-EE` | Swedish | `sv-SE` | +| French | `fr-FR` | Turkish | `tr-TR` | +| German | `de-DE` | Urdu | `ur-PK` | +| Hindi | `hi-IN` | Uzbek | `uz-UZ` | +| Hungarian | `hu-HU` | Vietnamese | `vi-VN` | +| Indonesian | `id-ID` | | | -### How RTL Support Works +Formbricks matches the survey's active language to the closest available bundle — for example, `de-AT` and +`de` both use the German bundle, and `pt-PT` uses `pt-BR`. Writing script is preserved when matching, so +`zh-Hant` and `zh-TW` resolve to the Traditional Chinese bundle rather than the Simplified one. If a language +has no matching bundle, your translated survey content is still shown as usual, but these built-in interface +strings fall back to English. -- Text alignment automatically switches to right-to-left -- Survey layout and UI elements adjust to RTL orientation -- Button placement and navigation flow adapt to RTL reading direction -- Form elements maintain proper RTL formatting + + Don't see your language? These interface translations live in the open-source `@formbricks/surveys` package. + You can add a new one by contributing a locale file on + [GitHub](https://github.com/formbricks/formbricks). + -### Setting Up RTL Languages +--- - - - Add an RTL language (like Arabic or Hebrew) in the **Survey Languages** settings - +## Analyzing multi-language responses - - Create translations for your survey content in the RTL language - +Every response records the language it was answered in, so you can: - - The survey will automatically display in RTL format when that language is selected +- Filter responses by **Language** on the summary and responses pages +- See the language of a single response on its response card - ![RTL Language Support](/images/surveys/general-features/multi-language-surveys/rtl-support.webp) - - +Choice answers are stored in the language the respondent used, and the summary maps them back to the +default-language choice — so one bar per choice, counted across all languages, rather than one bar per +language. --- diff --git a/packages/database/package.json b/packages/database/package.json index e289376fae72..b1487dbb3240 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -55,6 +55,7 @@ "db:push": "prisma db push --accept-data-loss --config ./prisma.config.ts", "db:seed": "dotenv -e ../../.env -- tsx src/seed.ts", "db:seed:clear": "dotenv -e ../../.env -- tsx src/seed.ts --clear", + "db:seed:contract": "dotenv -e ../../.env -- tsx src/scripts/seed-contract-fixtures.ts", "db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev", "db:start": "pnpm db:setup", "format": "prisma format --config ./prisma.config.ts", diff --git a/packages/database/src/scripts/seed-contract-fixtures.ts b/packages/database/src/scripts/seed-contract-fixtures.ts new file mode 100644 index 000000000000..5d11d13c0277 --- /dev/null +++ b/packages/database/src/scripts/seed-contract-fixtures.ts @@ -0,0 +1,278 @@ +/** + * Seeds the disposable resources the v3 OpenAPI contract tests mutate, and writes the id map the + * Schemathesis hooks read (see docs/api-v3-reference/contract-tests/). + * + * Why a separate set of resources: Schemathesis executes one case per operation in no guaranteed + * order, so pointing `DELETE /api/v3/surveys/{surveyId}` at the same survey `GET` reads would make + * coverage depend on execution order. Every mutating operation therefore gets its own victim, which + * keeps the read fixtures intact and lets the destructive operations answer their real 200/204 so + * those response shapes are schema-checked too. + * + * Requires `db:seed` to have run first (the workspace and the trigger survey come from there). The + * id map is written to docs/api-v3-reference/contract-tests/fixtures.json unless `--out` says + * otherwise. + */ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { logger } from "@formbricks/logger"; +import { type TSurveyBlocks } from "@formbricks/types/surveys/blocks"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { PrismaClient } from "../prisma"; +import { createPrismaPgAdapter } from "../prisma-adapter"; +import { SEED_CREDENTIALS, SEED_IDS } from "../seed/constants"; + +const prisma = new PrismaClient({ adapter: createPrismaPgAdapter().adapter }); + +// This script writes rows and re-archives surveys by fixed id; same posture as seed.ts, which it +// depends on anyway. Refuse to run against a production database unless someone says otherwise. +if (process.env.NODE_ENV === "production" && process.env.ALLOW_SEED !== "true") { + logger.error("ERROR: Seeding blocked in production. Set ALLOW_SEED=true to override."); + process.exit(1); +} + +/** Fixed ids so the hook map is static. Lowercase alphanumeric to satisfy the routes' `z.cuid2()`. */ +const CONTRACT_IDS = { + SURVEY_READ: "clctsurveyread0000000001", + SURVEY_PATCH: "clctsurveypatch000000001", + SURVEY_DELETE: "clctsurveydelete00000001", + SURVEY_ARCHIVE: "clctsurveyarchive0000001", + SURVEY_RESTORE: "clctsurveyrestore0000001", + WORKFLOW_PATCH: "clctworkflowpatch0000001", + WORKFLOW_DELETE: "clctworkflowdelete000001", + WORKFLOW_DUPLICATE: "clctworkflowduplicate001", + WORKFLOW_ENABLE: "clctworkflowenable000001", + WORKFLOW_DISABLE: "clctworkflowdisable00001", + WORKFLOW_ARCHIVE: "clctworkflowarchive00001", + WORKFLOW_UNARCHIVE: "clctworkflowunarchive001", + WORKFLOW_TEST: "clctworkflowtest00000001", + ACTION_CLASS_READ: "clctactionclassread00001", +} as const; + +/** + * Where the id map goes. Defaults to the contract-tests directory that reads it, resolved from this + * file rather than the working directory — `pnpm --filter` runs scripts from the package root, so a + * caller-supplied relative path would mean something different from what the caller typed. + */ +function getOutPath(): string { + const index = process.argv.indexOf("--out"); + const override = index === -1 ? undefined : process.argv[index + 1]; + + // A trailing `--out` with no value is otherwise indistinguishable from no `--out` at all: the map + // would be written to the default path — the very file the caller was redirecting away from — with no + // diagnostic. Easy to hit with `--out "$SOME_UNSET_VAR"`, so fail loudly instead. + if (index !== -1 && !override) { + throw new Error("--out requires a path argument."); + } + + if (override) { + return resolve(process.cwd(), override); + } + + return fileURLToPath( + new URL("../../../../docs/api-v3-reference/contract-tests/fixtures.json", import.meta.url) + ); +} + +/** + * Languages the `lang` examples on `GET /api/v3/surveys/{surveyId}` ask for. The endpoint answers a + * documented 400 for a language the survey does not configure, so without these the whole operation + * only ever exercises its error path and the survey resource schema — the largest in the contract — + * is never validated. + */ +const READ_SURVEY_LANGUAGES = ["en-US", "de-DE", "pt-PT", "zh-Hans", "zh-Hans-CN"] as const; + +async function seedSurveyLanguages(surveyId: string, codes: readonly string[]): Promise { + for (const [index, code] of codes.entries()) { + const language = await prisma.language.upsert({ + where: { workspaceId_code: { workspaceId: SEED_IDS.WORKSPACE, code } }, + update: {}, + create: { code, workspaceId: SEED_IDS.WORKSPACE }, + }); + + await prisma.surveyLanguage.upsert({ + where: { languageId_surveyId: { languageId: language.id, surveyId } }, + update: { enabled: true, default: index === 0 }, + create: { languageId: language.id, surveyId, enabled: true, default: index === 0 }, + }); + } +} + +async function seedSurvey(id: string, name: string, archived: boolean): Promise { + const blocks = [ + { + id: `${id}block`, + name: "Main Block", + elements: [ + { + id: `${id}element`, + type: "openText", + headline: { default: "Contract fixture question" }, + required: false, + }, + ], + }, + ] as unknown as TSurveyBlocks; + + const fields = { + name, + workspaceId: SEED_IDS.WORKSPACE, + status: "inProgress" as const, + type: "link" as const, + blocks, + archivedAt: archived ? new Date() : null, + }; + + await prisma.survey.upsert({ where: { id }, update: fields, create: { id, ...fields } }); +} + +async function seedWorkflow( + id: string, + name: string, + status: "draft" | "enabled" | "disabled" | "archived" +): Promise { + const triggerId = `${id}trigger`; + const actionId = `${id}action`; + + const definition: TWorkflowDefinition = { + schemaVersion: 1, + entryNodeId: triggerId, + trigger: { + id: triggerId, + type: "trigger", + triggerType: "response.completed", + config: { surveyId: SEED_IDS.SURVEY_KITCHEN_SINK, endingCardIds: [] }, + ui: { position: { x: 220, y: 80 } }, + }, + nodes: [ + { + id: actionId, + type: "action", + actionType: "send_email", + label: "Send email", + config: { + // Must be a workspace MEMBER, not an arbitrary address: `enable` and `testWorkflow` run the + // ENG-2029 recipient allowlist (`verifyRecipientsAllowed` → `getWorkspaceMemberEmails`), so a + // non-member recipient makes enable answer 422 `workflow_not_executable` and testWorkflow + // answer `{ok:false, recipient_not_allowed}`. Both are documented, so the suite would stay + // green while never schema-checking the success bodies these fixtures exist for. The admin is + // the organization owner, so it passes. + to: SEED_CREDENTIALS.ADMIN.email, + from: "team@example.com", + replyTo: [], + subject: "Contract fixture", + body: "Contract fixture body.", + attachResponseData: false, + }, + ui: { position: { x: 220, y: 200 } }, + }, + ], + edges: [{ id: `${id}edge`, source: triggerId, target: actionId }], + }; + + const fields = { + name, + description: "Disposable fixture for the v3 API contract tests.", + status, + definition, + workspaceId: SEED_IDS.WORKSPACE, + }; + + await prisma.workflow.upsert({ where: { id }, update: fields, create: { id, ...fields } }); +} + +async function main(): Promise { + const outPath = getOutPath(); + + const workspace = await prisma.workspace.findUnique({ where: { id: SEED_IDS.WORKSPACE } }); + if (!workspace) { + throw new Error(`Workspace ${SEED_IDS.WORKSPACE} is missing — run \`db:seed\` before this script.`); + } + + await seedSurvey(CONTRACT_IDS.SURVEY_READ, "Contract fixture — read", false); + await seedSurveyLanguages(CONTRACT_IDS.SURVEY_READ, READ_SURVEY_LANGUAGES); + await seedSurvey(CONTRACT_IDS.SURVEY_PATCH, "Contract fixture — patch", false); + await seedSurvey(CONTRACT_IDS.SURVEY_DELETE, "Contract fixture — delete", false); + await seedSurvey(CONTRACT_IDS.SURVEY_ARCHIVE, "Contract fixture — archive", false); + // Restore only has something to do on an already-archived survey. + await seedSurvey(CONTRACT_IDS.SURVEY_RESTORE, "Contract fixture — restore", true); + + await seedWorkflow(CONTRACT_IDS.WORKFLOW_PATCH, "Contract fixture — patch", "draft"); + await seedWorkflow(CONTRACT_IDS.WORKFLOW_DELETE, "Contract fixture — delete", "draft"); + await seedWorkflow(CONTRACT_IDS.WORKFLOW_DUPLICATE, "Contract fixture — duplicate", "draft"); + // `enable` only accepts draft/disabled rows; `disable` and `archive` need a live one. + await seedWorkflow(CONTRACT_IDS.WORKFLOW_ENABLE, "Contract fixture — enable", "draft"); + await seedWorkflow(CONTRACT_IDS.WORKFLOW_DISABLE, "Contract fixture — disable", "enabled"); + await seedWorkflow(CONTRACT_IDS.WORKFLOW_ARCHIVE, "Contract fixture — archive", "enabled"); + await seedWorkflow(CONTRACT_IDS.WORKFLOW_UNARCHIVE, "Contract fixture — unarchive", "archived"); + // `test` is a dry run, but it resolves the trigger and the recipient allowlist for real, so it needs + // a definition whose recipient is a workspace member — which the base seed's demo workflows are not. + await seedWorkflow(CONTRACT_IDS.WORKFLOW_TEST, "Contract fixture — test", "draft"); + + // An empty collection response satisfies the list schema without ever validating an item, so the + // read fixtures below exist to put at least one row in front of every list endpoint. + await prisma.actionClass.upsert({ + where: { id: CONTRACT_IDS.ACTION_CLASS_READ }, + update: {}, + create: { + id: CONTRACT_IDS.ACTION_CLASS_READ, + name: "Contract fixture — action class", + description: "Disposable fixture for the v3 API contract tests.", + type: "code", + key: "contract-fixture-action", + workspaceId: SEED_IDS.WORKSPACE, + }, + }); + + // No tag fixtures: every /api/v3/tags operation is `auth: "session"`, so an API key is rejected + // with a documented 401 before a handler ever looks for a row. Seeding them would read as coverage + // that does not exist. Add them here if tags ever accept an API key. + + const workflowRun = await prisma.workflowRun.findFirst({ + where: { workspaceId: SEED_IDS.WORKSPACE }, + select: { id: true }, + orderBy: { createdAt: "desc" }, + }); + + /** + * Consumed by docs/api-v3-reference/contract-tests/hooks.py. `read` is keyed by parameter name and + * applies to any operation without a more specific entry; `operations` is keyed by operationId and + * wins over it. Anything absent from both keeps its generated value and gets the documented 403. + */ + const fixtures = { + workspaceId: SEED_IDS.WORKSPACE, + read: { + surveyId: CONTRACT_IDS.SURVEY_READ, + workflowId: SEED_IDS.WORKFLOW_RESPONSE_FOLLOW_UP, + ...(workflowRun ? { runId: workflowRun.id } : {}), + }, + operations: { + patchSurveyV3: { path: { surveyId: CONTRACT_IDS.SURVEY_PATCH } }, + deleteSurveyV3: { path: { surveyId: CONTRACT_IDS.SURVEY_DELETE } }, + archiveSurveyV3: { path: { surveyId: CONTRACT_IDS.SURVEY_ARCHIVE } }, + restoreSurveyV3: { path: { surveyId: CONTRACT_IDS.SURVEY_RESTORE } }, + patchWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_PATCH } }, + deleteWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_DELETE } }, + duplicateWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_DUPLICATE } }, + enableWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_ENABLE } }, + disableWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_DISABLE } }, + archiveWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_ARCHIVE } }, + unarchiveWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_UNARCHIVE } }, + testWorkflowV3: { path: { workflowId: CONTRACT_IDS.WORKFLOW_TEST } }, + }, + }; + + writeFileSync(outPath, `${JSON.stringify(fixtures, null, 2)}\n`); + logger.info(`Seeded v3 contract fixtures and wrote the id map to ${outPath}.`); +} + +main() + .catch((error: unknown) => { + logger.error(error); + process.exit(1); + }) + .finally(() => { + prisma.$disconnect().catch((error: unknown) => { + logger.error(error, "Error disconnecting prisma"); + }); + }); diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 265e0c2542ac..e41ba8ba4893 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -1,5 +1,6 @@ import { createId } from "@paralleldrive/cuid2"; import bcryptjs from "bcryptjs"; +import { createHash } from "node:crypto"; import { logger } from "@formbricks/logger"; import { type TSurveyBlocks } from "@formbricks/types/surveys/blocks"; import type { @@ -413,6 +414,61 @@ async function seedDemoWorkflowRuns( } } +/** + * Create a management API key with `manage` on the seeded workspace, so the seeded data can be driven + * over HTTP (contract tests, manual API pokes) without clicking a key out of the UI. + * + * Opt-in through `SEED_API_KEY`: nothing is created when it is unset, and the secret is never written + * to the repo — callers pass a throwaway value (CI generates one per run). The key sent as + * `x-api-key` is `fbk_${SEED_API_KEY}`. + * + * Mirrors `createApiKey` in apps/web/modules/organization/settings/api-keys/lib/api-key.ts: SHA-256 + * `lookupHash` for the indexed lookup plus a bcrypt `hashedKey` for verification. That module is + * `server-only` and cannot be imported here, so the two hashing lines are inlined rather than shared. + */ +async function seedApiKey(organizationId: string, workspaceId: string, secret: string): Promise { + // `lookupHash` is a deterministic fingerprint for the indexed lookup, not the verification hash — + // it has to be reproducible from the presented key, so it cannot be salted or slow. Verification + // is the bcrypt hash below, which is what the auth path actually compares against. Same two-hash + // split as `createApiKey`. CodeQL reads the SHA-256 alone as `js/insufficient-password-hash`; that + // alert is dismissed as a false positive on this repo wherever the pattern appears (crypto.ts's + // `hashSha256` carries the same dismissal), since code scanning ignores inline suppressions. + const lookupHash = createHash("sha256").update(secret).digest("hex"); + const hashedKey = await bcryptjs.hash(secret, 12); + + // Keyed on the fixed seed id, not on `lookupHash`: re-seeding with a different `SEED_API_KEY` + // produces a different lookup hash, which would miss the row and then collide on the id. + // Workspace-scoped access only. The organization-level grants exist for the RBAC endpoints, which + // nothing driving the seeded data needs — no reason for this key to carry them. Shared by both + // branches below so they cannot drift. + const seedApiKeyOrgAccess = { accessControl: { read: false, write: false } }; + + const apiKey = await prisma.apiKey.upsert({ + where: { id: SEED_IDS.API_KEY }, + // Declarative on purpose: updating only the hashes would leave a row seeded by an earlier revision + // carrying its old organization-level grants forever, since re-seeding finds it by id and never + // rewrites those fields. Every field `create` sets, `update` must set too, or the two converge only + // on a fresh database. + update: { hashedKey, lookupHash, organizationId, organizationAccess: seedApiKeyOrgAccess }, + create: { + id: SEED_IDS.API_KEY, + label: "Seed API key", + hashedKey, + lookupHash, + organizationId, + organizationAccess: seedApiKeyOrgAccess, + }, + }); + + await prisma.apiKeyWorkspace.upsert({ + where: { apiKeyId_workspaceId: { apiKeyId: apiKey.id, workspaceId } }, + update: { permission: "manage" }, + create: { apiKeyId: apiKey.id, workspaceId, permission: "manage" }, + }); + + logger.info(`Seeded API key ${apiKey.id} with manage access to workspace ${workspaceId}.`); +} + async function deleteData(): Promise { logger.info("Clearing existing data..."); @@ -799,6 +855,13 @@ async function main(): Promise { }, }); + // Declared in turbo.json under `globalPassThroughEnv`, not `globalEnv`: it is a per-run random + // value that no build output depends on, so hashing it would invalidate cached tasks for nothing. + const seedApiKeySecret = process.env.SEED_API_KEY; + if (seedApiKeySecret) { + await seedApiKey(organization.id, workspace.id, seedApiKeySecret); + } + // Keep seed defaults aligned with production v5 camelCase keys. // Safe-identifier migration is deferred to v5.1. // Contact attribute keys for the workspace diff --git a/packages/database/src/seed/constants.ts b/packages/database/src/seed/constants.ts index f588d460a21a..9df29af95b4c 100644 --- a/packages/database/src/seed/constants.ts +++ b/packages/database/src/seed/constants.ts @@ -4,6 +4,7 @@ export const SEED_IDS = { USER_MEMBER: "clseedmember00000000000", ORGANIZATION: "clseedorg0000000000000", WORKSPACE: "clseedworkspace000000000", + API_KEY: "clseedapikey000000000", ENV_PROD: "clseedenvprod000000000", SURVEY_KITCHEN_SINK: "clseedsurveykitchen00", SURVEY_CSAT: "clseedsurveycsat000000", diff --git a/turbo.json b/turbo.json index 66a8a79abda6..5128bee8bda3 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,7 @@ "turbo.json" ], "globalEnv": ["MIGRATE_DATABASE_URL", "SHADOW_DATABASE_URL"], + "globalPassThroughEnv": ["SEED_API_KEY"], "tasks": { "@formbricks/ai#build": { "dependsOn": ["@formbricks/logger#build"],