Merge branch 'main' into feat/bulk-contacts-api

This commit is contained in:
pandeymangg
2025-03-19 13:50:04 +05:30
219 changed files with 10876 additions and 4343 deletions

View File

@@ -97,6 +97,9 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
# DOCKER_CRON_ENABLED=1
##########
# Other #
##########
@@ -185,7 +188,9 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -202,7 +207,7 @@ UNKEY_ROOT_KEY=
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
# AI_AZURE_LLM_DEPLOYMENT_ID=
# NEXT_PUBLIC_INTERCOM_APP_ID=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Enable Prometheus metrics

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@v2
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit

View File

@@ -142,7 +142,7 @@ jobs:
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
if: failure()
with:
name: app-logs

View File

@@ -1,67 +0,0 @@
name: Prepare release
run-name: Prepare release ${{ inputs.next_version }}
on:
workflow_dispatch:
inputs:
next_version:
required: true
type: string
description: "Version name"
permissions:
contents: write
pull-requests: write
jobs:
prepare_release:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Bump version
run: |
cd apps/web
pnpm version ${{ inputs.next_version }} --no-workspaces-update
- name: Commit changes and create a branch
run: |
branch_name="release-v${{ inputs.next_version }}"
git checkout -b "$branch_name"
git add .
git commit -m "chore: release v${{ inputs.next_version }}"
git push origin "$branch_name"
- name: Create pull request
run: |
gh pr create \
--base main \
--head "release-v${{ inputs.next_version }}" \
--title "chore: bump version to v${{ inputs.next_version }}" \
--body "This PR contains the changes for the v${{ inputs.next_version }} release."
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -42,6 +42,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0

View File

@@ -27,6 +27,18 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Log in to Docker Hub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
with:
@@ -36,13 +48,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1
with:

View File

@@ -5,6 +5,9 @@ on:
types:
- published
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
@@ -12,14 +15,19 @@ jobs:
packages: write
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@v3
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
@@ -27,7 +35,7 @@ jobs:
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@v1.3.1
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |

View File

@@ -19,17 +19,22 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: Terraform Format
id: fmt
@@ -53,7 +58,7 @@ jobs:
working-directory: infra/terraform
- name: Post PR comment
uses: borchero/terraform-plan-comment@v2
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
if: always() && github.ref != 'refs/heads/main' && (steps.validate.outcome == 'success' || steps.validate.outcome == 'failure')
with:
token: ${{ github.token }}

View File

@@ -9,6 +9,12 @@ declare const window: Window;
export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => {
if (darkMode) {
@@ -33,18 +39,9 @@ export default function AppPage(): React.JSX.Element {
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userInitAttributes = {
language: "de",
"Init Attribute 1": "eight",
"Init Attribute 2": "two",
};
void formbricks.init({
void formbricks.setup({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
attributes: userInitAttributes,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
});
}
@@ -126,19 +123,19 @@ export default function AppPage(): React.JSX.Element {
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
Set a user ID / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "}
<strong>reinitialized</strong>.
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
the local state gets <strong>updated with the user state</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button"
onClick={() => {
void formbricks.reset();
void formbricks.setUserId(userId);
}}>
Reset
Set user ID
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
@@ -158,7 +155,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/actions/no-code"
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
@@ -166,7 +163,7 @@ export default function AppPage(): React.JSX.Element {
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/actions/no-code"
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
@@ -175,6 +172,7 @@ export default function AppPage(): React.JSX.Element {
</p>
</div>
</div>
<div className="p-6">
<div>
<button
@@ -190,7 +188,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/attributes/custom-attributes"
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
@@ -215,7 +213,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/attributes/custom-attributes"
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
@@ -240,7 +238,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
@@ -250,6 +248,110 @@ export default function AppPage(): React.JSX.Element {
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -111,7 +111,12 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD supercronic -quiet /app/docker/cronjobs & \
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

View File

@@ -34,10 +34,9 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "${webAppUrl}";
var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}";
var userId = "testUser";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl})},500)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -45,9 +44,9 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "${webAppUrl}";
var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost})},500)}();
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl })},500)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -56,10 +55,9 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
formbricks.setup({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
userId: "testUser",
appUrl: "${webAppUrl}",
});
}
@@ -75,9 +73,9 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
formbricks.setup({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
appUrl: "${webAppUrl}",
});
}

View File

@@ -12,12 +12,12 @@ export const FormbricksClient = ({ userId, email }: { userId: string; email: str
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.init({
formbricks.setup({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId,
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email]);

View File

@@ -12,7 +12,6 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import React from "react";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";

View File

@@ -2,7 +2,6 @@
import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react";
import React from "react";
import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({

View File

@@ -16,6 +16,7 @@ interface LinkTabProps {
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
{
title: t("environments.surveys.summary.data_prefilling"),
@@ -48,6 +49,7 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
locale={locale}
/>
</div>
<div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700">
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡

View File

@@ -0,0 +1,36 @@
import { Options } from "qr-code-styling";
export const getQRCodeOptions = (width: number, height: number): Options => ({
width,
height,
type: "svg",
data: "",
margin: 0,
qrOptions: {
typeNumber: 0,
mode: "Byte",
errorCorrectionLevel: "L",
},
imageOptions: {
saveAsBlob: true,
hideBackgroundDots: false,
imageSize: 0,
margin: 0,
},
dotsOptions: {
type: "extra-rounded",
color: "#000000",
roundSize: true,
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "dot",
color: "#000000",
},
cornersDotOptions: {
type: "dot",
color: "#000000",
},
});

View File

@@ -0,0 +1,44 @@
"use client";
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
import { useTranslate } from "@tolgee/react";
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react";
import { toast } from "react-hot-toast";
export const useSurveyQRCode = (surveyUrl: string) => {
const qrCodeRef = useRef<HTMLDivElement>(null);
const qrInstance = useRef<QRCodeStyling | null>(null);
const { t } = useTranslate();
useEffect(() => {
try {
if (!qrInstance.current) {
qrInstance.current = new QRCodeStyling(getQRCodeOptions(70, 70));
}
if (surveyUrl && qrInstance.current) {
qrInstance.current.update({ data: surveyUrl });
if (qrCodeRef.current) {
qrCodeRef.current.innerHTML = "";
qrInstance.current.append(qrCodeRef.current);
}
}
} catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
}
}, [surveyUrl]);
const downloadQRCode = () => {
try {
const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500));
downloadInstance.update({ data: surveyUrl });
downloadInstance.download({ name: "survey-qr", extension: "png" });
} catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
}
};
return { qrCodeRef, downloadQRCode };
};

View File

@@ -0,0 +1,92 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user";
import AppLayout from "./layout";
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="ph-provider">{children}</div>
),
PostHogPageview: () => <div data-testid="ph-pageview" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
describe("(app) AppLayout", () => {
afterEach(() => {
cleanup();
});
it("renders child content and all sub-components when user exists", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
// Because AppLayout is async, call it like a function
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
});
it("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,11 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClient } from "@/app/IntercomClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => {
@@ -22,11 +21,7 @@ const AppLayout = async ({ children }) => {
<PHProvider>
<>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
intercomSecretKey={INTERCOM_SECRET_KEY}
user={user}
/>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>

View File

@@ -0,0 +1,34 @@
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import AppLayout from "../(auth)/layout";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_INTERCOM_CONFIGURED: true,
INTERCOM_SECRET_KEY: "mock-intercom-secret-key",
INTERCOM_APP_ID: "mock-intercom-app-id",
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="mock-no-mobile-overlay" />,
}));
describe("(auth) AppLayout", () => {
it("renders the NoMobileOverlay and IntercomClient, plus children", async () => {
const appLayoutElement = await AppLayout({
children: <div data-testid="child-content">Hello from children!</div>,
});
const childContentText = "Hello from children!";
render(appLayoutElement);
expect(screen.getByTestId("mock-no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent(childContentText);
});
});

View File

@@ -1,12 +1,11 @@
import { IntercomClient } from "@/app/IntercomClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClient isIntercomConfigured={IS_INTERCOM_CONFIGURED} intercomSecretKey={INTERCOM_SECRET_KEY} />
<IntercomClientWrapper />
{children}
</>
);

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route";
export { GET };

View File

@@ -0,0 +1,4 @@
import { ContactSurveyPage, generateMetadata } from "@/modules/survey/link/contact-survey/page";
export { generateMetadata };
export default ContactSurveyPage;

View File

@@ -0,0 +1,186 @@
import Intercom from "@intercom/messenger-js-sdk";
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { IntercomClient } from "./IntercomClient";
// Mock the Intercom package
vi.mock("@intercom/messenger-js-sdk", () => ({
default: vi.fn(),
}));
describe("IntercomClient", () => {
let originalWindowIntercom: any;
let mockWindowIntercom = vi.fn();
beforeEach(() => {
// Save original window.Intercom so we can restore it later
originalWindowIntercom = global.window?.Intercom;
// Mock window.Intercom so we can verify the shutdown call on unmount
global.window.Intercom = mockWindowIntercom;
});
afterEach(() => {
cleanup();
// Restore the original window.Intercom
global.window.Intercom = originalWindowIntercom;
});
it("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => {
const testUser = {
id: "test-id",
name: "Test User",
email: "test@example.com",
createdAt: new Date("2020-01-01T00:00:00Z"),
} as TUser;
render(
<IntercomClient
isIntercomConfigured={true}
intercomUserHash="my-user-hash"
intercomAppId="my-app-id"
user={testUser}
/>
);
// Verify Intercom was called with the expected params
expect(Intercom).toHaveBeenCalledTimes(1);
expect(Intercom).toHaveBeenCalledWith({
app_id: "my-app-id",
user_id: "test-id",
user_hash: "my-user-hash",
name: "Test User",
email: "test@example.com",
created_at: 1577836800, // Epoch for 2020-01-01T00:00:00Z
});
});
it("calls Intercom with user data without createdAt", () => {
const testUser = {
id: "test-id",
name: "Test User",
email: "test@example.com",
} as TUser;
render(
<IntercomClient
isIntercomConfigured={true}
intercomUserHash="my-user-hash"
intercomAppId="my-app-id"
user={testUser}
/>
);
// Verify Intercom was called with the expected params
expect(Intercom).toHaveBeenCalledTimes(1);
expect(Intercom).toHaveBeenCalledWith({
app_id: "my-app-id",
user_id: "test-id",
user_hash: "my-user-hash",
name: "Test User",
email: "test@example.com",
created_at: undefined,
});
});
it("calls Intercom with minimal params if user is not provided", () => {
render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
);
expect(Intercom).toHaveBeenCalledTimes(1);
expect(Intercom).toHaveBeenCalledWith({
app_id: "my-app-id",
});
});
it("does not call Intercom if isIntercomConfigured is false", () => {
render(
<IntercomClient
isIntercomConfigured={false}
intercomAppId="my-app-id"
user={{ id: "whatever" } as TUser}
/>
);
expect(Intercom).not.toHaveBeenCalled();
});
it("shuts down Intercom on unmount", () => {
const { unmount } = render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
);
// Reset call count; we only care about the shutdown after unmount
mockWindowIntercom.mockClear();
unmount();
// Intercom should be shut down on unmount
expect(mockWindowIntercom).toHaveBeenCalledWith("shutdown");
});
it("logs an error if Intercom initialization fails", () => {
// Spy on console.error
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Force Intercom to throw an error on invocation
vi.mocked(Intercom).mockImplementationOnce(() => {
throw new Error("Intercom test error");
});
// Render the component with isIntercomConfigured=true so it tries to initialize
render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
);
// Verify that console.error was called with the correct message
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to initialize Intercom:", expect.any(Error));
// Clean up the spy
consoleErrorSpy.mockRestore();
});
it("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<IntercomClient
isIntercomConfigured={true}
// missing intercomAppId
intercomUserHash="my-user-hash"
/>
);
// We expect a caught error: "Intercom app ID is required"
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to initialize Intercom:", expect.any(Error));
const [, caughtError] = consoleErrorSpy.mock.calls[0];
expect((caughtError as Error).message).toBe("Intercom app ID is required");
consoleErrorSpy.mockRestore();
});
it("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const testUser = {
id: "test-id",
name: "Test User",
email: "test@example.com",
} as TUser;
render(
<IntercomClient
isIntercomConfigured={true}
intercomAppId="some-app-id"
user={testUser}
// missing intercomUserHash
/>
);
// We expect a caught error: "Intercom user hash is required"
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to initialize Intercom:", expect.any(Error));
const [, caughtError] = consoleErrorSpy.mock.calls[0];
expect((caughtError as Error).message).toBe("Intercom user hash is required");
consoleErrorSpy.mockRestore();
});
});

View File

@@ -1,30 +1,31 @@
"use client";
import Intercom from "@intercom/messenger-js-sdk";
import { createHmac } from "crypto";
import { useCallback, useEffect } from "react";
import { env } from "@formbricks/lib/env";
import { TUser } from "@formbricks/types/user";
const intercomAppId = env.NEXT_PUBLIC_INTERCOM_APP_ID;
interface IntercomClientProps {
isIntercomConfigured: boolean;
intercomSecretKey?: string;
intercomUserHash?: string;
user?: TUser | null;
intercomAppId?: string;
}
export const IntercomClient = ({ user, intercomSecretKey, isIntercomConfigured }: IntercomClientProps) => {
export const IntercomClient = ({
user,
intercomUserHash,
isIntercomConfigured,
intercomAppId,
}: IntercomClientProps) => {
const initializeIntercom = useCallback(() => {
let initParams = {};
if (user) {
if (user && intercomUserHash) {
const { id, name, email, createdAt } = user;
const hash = createHmac("sha256", intercomSecretKey!).update(user?.id).digest("hex");
initParams = {
user_id: id,
user_hash: hash,
user_hash: intercomUserHash,
name,
email,
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
@@ -35,11 +36,21 @@ export const IntercomClient = ({ user, intercomSecretKey, isIntercomConfigured }
app_id: intercomAppId!,
...initParams,
});
}, [user, intercomSecretKey]);
}, [user, intercomUserHash, intercomAppId]);
useEffect(() => {
try {
if (isIntercomConfigured) initializeIntercom();
if (isIntercomConfigured) {
if (!intercomAppId) {
throw new Error("Intercom app ID is required");
}
if (user && !intercomUserHash) {
throw new Error("Intercom user hash is required");
}
initializeIntercom();
}
return () => {
// Shutdown Intercom when component unmounts
@@ -50,7 +61,7 @@ export const IntercomClient = ({ user, intercomSecretKey, isIntercomConfigured }
} catch (error) {
console.error("Failed to initialize Intercom:", error);
}
}, [isIntercomConfigured, initializeIntercom]);
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
return null;
};

View File

@@ -0,0 +1,64 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { IntercomClientWrapper } from "./IntercomClientWrapper";
vi.mock("@formbricks/lib/constants", () => ({
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "mock-intercom-app-id",
INTERCOM_SECRET_KEY: "mock-intercom-secret-key",
}));
// Mock the crypto createHmac function to return a fake hash.
// Vite global setup doesn't work here due to Intercom probably using crypto themselves.
vi.mock("crypto", () => ({
default: {
createHmac: vi.fn(() => ({
update: vi.fn().mockReturnThis(),
digest: vi.fn().mockReturnValue("fake-hash"),
})),
},
}));
vi.mock("./IntercomClient", () => ({
IntercomClient: (props: any) => (
<div data-testid="mock-intercom-client" data-props={JSON.stringify(props)} />
),
}));
describe("IntercomClientWrapper", () => {
afterEach(() => {
cleanup();
});
it("renders IntercomClient with computed user hash when user is provided", () => {
const testUser = { id: "user-123", name: "Test User", email: "test@example.com" } as TUser;
render(<IntercomClientWrapper user={testUser} />);
const intercomClientEl = screen.getByTestId("mock-intercom-client");
expect(intercomClientEl).toBeInTheDocument();
const props = JSON.parse(intercomClientEl.getAttribute("data-props") ?? "{}");
// Check that the computed hash equals "fake-hash" (as per our crypto mock)
expect(props.intercomUserHash).toBe("fake-hash");
expect(props.intercomAppId).toBe("mock-intercom-app-id");
expect(props.isIntercomConfigured).toBe(true);
expect(props.user).toEqual(testUser);
});
it("renders IntercomClient without computing a hash when no user is provided", () => {
render(<IntercomClientWrapper user={null} />);
const intercomClientEl = screen.getByTestId("mock-intercom-client");
expect(intercomClientEl).toBeInTheDocument();
const props = JSON.parse(intercomClientEl.getAttribute("data-props") ?? "{}");
expect(props.intercomUserHash).toBeUndefined();
expect(props.intercomAppId).toBe("mock-intercom-app-id");
expect(props.isIntercomConfigured).toBe(true);
expect(props.user).toBeNull();
});
});

View File

@@ -0,0 +1,26 @@
import { createHmac } from "crypto";
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
import type { TUser } from "@formbricks/types/user";
import { IntercomClient } from "./IntercomClient";
interface IntercomClientWrapperProps {
user?: TUser | null;
}
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
let intercomUserHash: string | undefined;
if (user) {
const secretKey = INTERCOM_SECRET_KEY;
if (secretKey) {
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
}
}
return (
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
user={user}
intercomAppId={INTERCOM_APP_ID}
intercomUserHash={intercomUserHash}
/>
);
};

View File

@@ -51,12 +51,16 @@ CacheHandler.onCreation(async () => {
let handler;
if (client?.isReady) {
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler({
const redisHandlerOptions = {
client,
keyPrefix: "fb:",
timeoutMs: 1000,
});
};
redisHandlerOptions.ttl = Number(process.env.REDIS_DEFAULT_TTL) || 86400; // 1 day
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
// Fallback to LRU handler if Redis client is not available.
// The application will still work, but the cache will be in memory only and not shared.

View File

@@ -1,10 +1,11 @@
"use client";
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Copy, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
import { Copy, QrCode, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -68,6 +69,8 @@ export const ShareSurveyLink = ({
getUrl();
}, [survey, getUrl, language]);
const { downloadQRCode } = useSurveyQRCode(surveyUrl);
return (
<div
className={`flex max-w-full flex-col items-center justify-center space-x-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
@@ -100,6 +103,14 @@ export const ShareSurveyLink = ({
{t("common.copy")}
<Copy />
</Button>
<Button
variant="secondary"
title={t("environments.surveys.summary.download_qr_code")}
aria-label={t("environments.surveys.summary.download_qr_code")}
size={"icon"}
onClick={downloadQRCode}>
<QrCode style={{ width: "24px", height: "24px" }} />
</Button>
{survey.singleUse?.enabled && (
<Button
title="Regenerate single use survey link"

View File

@@ -4,7 +4,7 @@ import { hashApiKey } from "../utils";
describe("hashApiKey", () => {
test("generate the correct sha256 hash for a given input", () => {
const input = "test";
const expectedHash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
const expectedHash = "fake-hash"; // mocked on the vitestSetup.ts file;
const result = hashApiKey(input);
expect(result).toEqual(expectedHash);
});
@@ -12,19 +12,6 @@ describe("hashApiKey", () => {
test("return a string with length 64", () => {
const input = "another-api-key";
const result = hashApiKey(input);
expect(result).toHaveLength(64);
});
test("produce the same hash for identical inputs", () => {
const input = "consistentKey";
const firstHash = hashApiKey(input);
const secondHash = hashApiKey(input);
expect(firstHash).toEqual(secondHash);
});
test("generate different hashes for different inputs", () => {
const hash1 = hashApiKey("key1");
const hash2 = hashApiKey("key2");
expect(hash1).not.toEqual(hash2);
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
});
});

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContact } from "./contacts";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
},
},
}));
describe("getContact", () => {
const mockContactId = "cm8fj8ry6000008l5daam88nc";
const mockEnvironmentId = "cm8fj8xt3000108l5art7594h";
const mockContact = {
id: mockContactId,
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contact when found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(mockContactId, mockEnvironmentId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: {
id: mockContactId,
environmentId: mockEnvironmentId,
},
select: {
id: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockContact);
}
});
test("returns null when contact not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(mockContactId, mockEnvironmentId);
expect(prisma.contact.findUnique).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "contact",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -0,0 +1,37 @@
import { contactCache } from "@/lib/cache/contact";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Contact } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContact = reactCache(async (contactId: string, environmentId: string) =>
cache(
async (): Promise<Result<Pick<Contact, "id">, ApiErrorResponseV2>> => {
try {
const contact = await prisma.contact.findUnique({
where: {
id: contactId,
environmentId,
},
select: {
id: true,
},
});
if (!contact) {
return err({ type: "not_found", details: [{ field: "contact", issue: "not found" }] });
}
return ok(contact);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "contact", issue: error.message }] });
}
},
[`contact-link-getContact-${contactId}-${environmentId}`],
{
tags: [contactCache.tag.byId(contactId), contactCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getResponse } from "./response";
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
findFirst: vi.fn(),
},
},
}));
describe("getResponse", () => {
const mockContactId = "cm8fj8xt3000108l5art7594h";
const mockSurveyId = "cm8fj9962000208l56jcu94i5";
const mockResponse = {
id: "cm8fj9gqp000308l5ab7y800j",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns response when found", async () => {
vi.mocked(prisma.response.findFirst).mockResolvedValue(mockResponse);
const result = await getResponse(mockContactId, mockSurveyId);
expect(prisma.response.findFirst).toHaveBeenCalledWith({
where: {
contactId: mockContactId,
surveyId: mockSurveyId,
},
select: {
id: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockResponse);
}
});
test("returns null when response not found", async () => {
vi.mocked(prisma.response.findFirst).mockResolvedValue(null);
const result = await getResponse(mockContactId, mockSurveyId);
expect(prisma.response.findFirst).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "response",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -0,0 +1,37 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getResponse = reactCache(async (contactId: string, surveyId: string) =>
cache(
async (): Promise<Result<Pick<Response, "id">, ApiErrorResponseV2>> => {
try {
const response = await prisma.response.findFirst({
where: {
contactId,
surveyId,
},
select: {
id: true,
},
});
if (!response) {
return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
}
return ok(response);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] });
}
},
[`contact-link-getResponse-${contactId}-${surveyId}`],
{
tags: [responseCache.tag.byId(contactId), responseCache.tag.bySurveyId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getSurvey } from "./surveys";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
describe("getSurvey", () => {
const mockSurveyId = "cm8fj9psb000408l50e1x4c6f";
const mockSurvey = {
id: mockSurveyId,
type: "web",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns survey when found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: {
id: mockSurveyId,
},
select: {
id: true,
type: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
});
test("returns null when survey not found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "survey",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -0,0 +1,35 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurvey = reactCache(async (surveyId: string) =>
cache(
async (): Promise<Result<Pick<Survey, "id" | "type">, ApiErrorResponseV2>> => {
try {
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
select: {
id: true,
type: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`contact-link-getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,111 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZContactLinkParams = z.object({
surveyId: ZId,
contactId: ZId,
});
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; contactId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: ZContactLinkParams,
},
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const surveyResult = await getSurvey(params.surveyId);
if (!surveyResult.ok) {
return handleApiError(request, surveyResult.error);
}
const survey = surveyResult.data;
if (!survey) {
return handleApiError(request, {
type: "not_found",
details: [{ field: "surveyId", issue: "Not found" }],
});
}
if (survey.type !== "link") {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "surveyId", issue: "Not a link survey" }],
});
}
// Check if contact exists and belongs to the environment
const contactResult = await getContact(params.contactId, environmentId);
if (!contactResult.ok) {
return handleApiError(request, contactResult.error);
}
const contact = contactResult.data;
if (!contact) {
return handleApiError(request, {
type: "not_found",
details: [{ field: "contactId", issue: "Not found" }],
});
}
// Check if contact has already responded to this survey
const existingResponseResult = await getResponse(params.contactId, params.surveyId);
if (existingResponseResult.ok) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactId", issue: "Already responded" }],
});
}
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);
}
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
},
});

View File

@@ -3,3 +3,9 @@ export type TOidcNameFields = {
family_name?: string;
preferred_username?: string;
};
export type TSamlNameFields = {
name?: string;
firstName?: string;
lastName?: string;
};

View File

@@ -5,6 +5,7 @@ import { NextRequest, userAgent } from "next/server";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsPersonState, ZJsUserIdentifyInput, ZJsUserUpdateInput } from "@formbricks/types/js";
import { ZUserEmail } from "@formbricks/types/user";
import { updateUser } from "./lib/update-user";
export const OPTIONS = async (): Promise<Response> => {
@@ -43,6 +44,17 @@ export const POST = async (
);
}
// validate email if present in attributes
if (parsedInput.data.attributes?.email) {
const emailValidation = ZUserEmail.safeParse(parsedInput.data.attributes.email);
if (!emailValidation.success) {
return responses.badRequestResponse(
"Invalid email",
transformErrorToDetails(emailValidation.error),
true
);
}
}
const { userId, attributes } = parsedInput.data;
const isContactsEnabled = await getIsContactsEnabled();

View File

@@ -5,7 +5,6 @@ import { debounce } from "lodash";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import React from "react";
import toast from "react-hot-toast";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TEnvironment } from "@formbricks/types/environment";

View File

@@ -27,7 +27,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import React, { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TContactTableData } from "../types/contact";
import { generateContactTableColumns } from "./contact-table-column";

View File

@@ -1,5 +1,4 @@
import { TContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import React from "react";
interface CsvTableProps {
data: TContactCSVUploadResponse;

View File

@@ -77,6 +77,13 @@ export const UploadContactsCSVButton = ({
return;
}
if (!parsedRecords.data.length) {
setErrror(
"The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format."
);
return;
}
setCSVResponse(parsedRecords.data);
} catch (error) {
console.error("Error parsing CSV:", error);

View File

@@ -0,0 +1,188 @@
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import * as crypto from "@formbricks/lib/crypto";
import * as contactSurveyLink from "./contact-survey-link";
// Mock all modules needed (this gets hoisted to the top of the file)
vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
},
}));
// Mock constants - MUST be a literal object without using variables
vi.mock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
WEBAPP_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
}));
describe("Contact Survey Link", () => {
const mockContactId = "contact-123";
const mockSurveyId = "survey-456";
const mockToken = "mock.jwt.token";
const mockEncryptedContactId = "encrypted-contact-id";
const mockEncryptedSurveyId = "encrypted-survey-id";
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
vi.mocked(crypto.symmetricEncrypt).mockImplementation((value) =>
value === mockContactId ? mockEncryptedContactId : mockEncryptedSurveyId
);
vi.mocked(crypto.symmetricDecrypt).mockImplementation((value) => {
if (value === mockEncryptedContactId) return mockContactId;
if (value === mockEncryptedSurveyId) return mockSurveyId;
return value;
});
vi.mocked(jwt.sign).mockReturnValue(mockToken as any);
vi.mocked(jwt.verify).mockReturnValue({
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
} as any);
});
describe("getContactSurveyLink", () => {
it("creates a survey link with encrypted contact and survey IDs", () => {
const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
// Verify encryption was called for both IDs
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockSurveyId, ENCRYPTION_KEY);
// Verify JWT sign was called with correct payload
expect(jwt.sign).toHaveBeenCalledWith(
{
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
},
ENCRYPTION_KEY,
{ algorithm: "HS256" }
);
// Verify the returned URL
expect(result).toEqual({
ok: true,
data: `${WEBAPP_URL}/c/${mockToken}`,
});
});
it("adds expiration to the token when expirationDays is provided", () => {
const expirationDays = 7;
contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
// Verify JWT sign was called with expiration
expect(jwt.sign).toHaveBeenCalledWith(
{
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
},
ENCRYPTION_KEY,
{ algorithm: "HS256", expiresIn: "7d" }
);
});
it("throws an error when ENCRYPTION_KEY is not available", async () => {
// Reset modules so the new mock is used by the module under test
vi.resetModules();
// Remock constants to simulate missing ENCRYPTION_KEY
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
}));
// Reimport the modules so they pick up the new mock
const { getContactSurveyLink } = await import("./contact-survey-link");
const result = getContactSurveyLink(mockContactId, mockSurveyId);
expect(result).toEqual({
ok: false,
error: {
type: "internal_server_error",
message: "Encryption key not found - cannot create personalized survey link",
},
});
});
});
describe("verifyContactSurveyToken", () => {
it("verifies and decrypts a valid token", () => {
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
// Verify JWT verify was called
expect(jwt.verify).toHaveBeenCalledWith(mockToken, ENCRYPTION_KEY);
// Check the decrypted result
expect(result).toEqual({
ok: true,
data: {
contactId: mockContactId,
surveyId: mockSurveyId,
},
});
});
it("throws an error when token verification fails", () => {
vi.mocked(jwt.verify).mockImplementation(() => {
throw new Error("Token verification failed");
});
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
});
it("throws an error when token has invalid format", () => {
// Mock JWT.verify to return an incomplete payload
vi.mocked(jwt.verify).mockReturnValue({
// Missing surveyId
contactId: mockEncryptedContactId,
} as any);
// Suppress console.error for this test
vi.spyOn(console, "error").mockImplementation(() => {});
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
});
it("throws an error when ENCRYPTION_KEY is not available", async () => {
vi.resetModules();
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
}));
const { verifyContactSurveyToken } = await import("./contact-survey-link");
const result = verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
message: "Encryption key not found - cannot verify survey token",
});
}
});
});
});

View File

@@ -0,0 +1,82 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
export const getContactSurveyLink = (
contactId: string,
surveyId: string,
expirationDays?: number
): Result<string, ApiErrorResponseV2> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
message: "Encryption key not found - cannot create personalized survey link",
});
}
// Encrypt the contact and survey IDs
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
// Create JWT payload with encrypted IDs
const payload = {
contactId: encryptedContactId,
surveyId: encryptedSurveyId,
};
// Set token options
const tokenOptions: jwt.SignOptions = {
algorithm: "HS256",
};
// Add expiration if specified
if (expirationDays !== undefined && expirationDays > 0) {
tokenOptions.expiresIn = `${expirationDays}d`;
}
// Sign the token with ENCRYPTION_KEY using SHA256
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
return ok(`${WEBAPP_URL}/c/${token}`);
};
// Validates and decrypts a contact survey JWT token
export const verifyContactSurveyToken = (
token: string
): Result<{ contactId: string; surveyId: string }, ApiErrorResponseV2> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
message: "Encryption key not found - cannot verify survey token",
});
}
try {
// Verify the token
const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string };
if (!decoded || !decoded.contactId || !decoded.surveyId) {
throw err("Invalid token format");
}
// Decrypt the contact and survey IDs
const contactId = symmetricDecrypt(decoded.contactId, ENCRYPTION_KEY);
const surveyId = symmetricDecrypt(decoded.surveyId, ENCRYPTION_KEY);
return ok({
contactId,
surveyId,
});
} catch (error) {
console.error("Error verifying contact survey token:", error);
return err({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
};

View File

@@ -43,19 +43,8 @@ export const InsightView = ({
const [activeTab, setActiveTab] = useState<string>("all");
const [visibleInsights, setVisibleInsights] = useState(10);
const handleFeedback = (feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback", {
hiddenFields: {
feedbackSentiment: feedback,
insightId: currentInsight?.id,
insightTitle: currentInsight?.title,
insightDescription: currentInsight?.description,
insightCategory: currentInsight?.category,
environmentId: currentInsight?.environmentId,
surveyId,
questionId,
},
});
const handleFeedback = (_feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback");
};
const handleFilterSelect = useCallback(

View File

@@ -42,17 +42,8 @@ export const InsightView = ({
const [currentInsight, setCurrentInsight] = useState<TInsightWithDocumentCount | null>(null);
const [activeTab, setActiveTab] = useState<string>("featureRequest");
const handleFeedback = (feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback", {
hiddenFields: {
feedbackSentiment: feedback,
insightId: currentInsight?.id,
insightTitle: currentInsight?.title,
insightDescription: currentInsight?.description,
insightCategory: currentInsight?.category,
environmentId: currentInsight?.environmentId,
},
});
const handleFeedback = (_feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback");
};
const insightsFilter: TInsightFilterCriteria = useMemo(

View File

@@ -1,7 +1,7 @@
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import { TOidcNameFields } from "@/modules/auth/types/auth";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import type { IdentityProvider } from "@prisma/client";
import type { Account } from "next-auth";
@@ -93,6 +93,15 @@ export const handleSSOCallback = async ({ user, account }: { user: TUser; accoun
}
}
if (provider === "saml") {
const samlUser = user as TUser & TSamlNameFields;
if (samlUser.name) {
userName = samlUser.name;
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
}
const userProfile = await createUser({
name:
userName ||

View File

@@ -11,7 +11,7 @@ import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";

View File

@@ -6,7 +6,7 @@ import { EnterCode } from "@/modules/ee/two-factor-auth/components/enter-code";
import { ScanQRCode } from "@/modules/ee/two-factor-auth/components/scan-qr-code";
import { Modal } from "@/modules/ui/components/modal";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useState } from "react";
export type EnableTwoFactorModalStep = "confirmPassword" | "scanQRCode" | "enterCode" | "backupCodes";

View File

@@ -4,7 +4,6 @@ import { FormField, FormItem } from "@/modules/ui/components/form";
import { FormControl } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import React from "react";
import { UseFormReturn } from "react-hook-form";
interface TwoFactorBackupProps {

View File

@@ -3,7 +3,6 @@
import { FormControl, FormField, FormItem } from "@/modules/ui/components/form";
import { OTPInput } from "@/modules/ui/components/otp-input";
import { useTranslate } from "@tolgee/react";
import React from "react";
import { UseFormReturn } from "react-hook-form";
interface TwoFactorProps {

View File

@@ -7,7 +7,6 @@ import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";

View File

@@ -1,7 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { TFnType } from "@tolgee/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EmailTemplate } from "./email-template";

View File

@@ -1,8 +1,8 @@
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FollowUpEmail } from "./follow-up";
vi.mock("@formbricks/lib/constants", () => ({
@@ -29,6 +29,10 @@ describe("FollowUpEmail", () => {
);
});
afterEach(() => {
cleanup();
});
it("renders the default logo if no custom logo is provided", async () => {
const followUpEmailElement = await FollowUpEmail({
...defaultProps,

View File

@@ -15,7 +15,7 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { SendHorizonalIcon, ShareIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TMember } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";

View File

@@ -40,13 +40,15 @@ export const SetupInstructions = ({ environmentId, webAppUrl }: SetupInstruction
<CodeBlock language="sh">yarn add @formbricks/js</CodeBlock>
<h4>{t("environments.project.app-connection.step_2")}</h4>
<p>{t("environments.project.app-connection.step_2_description")}</p>
<CodeBlock language="js">{`import formbricks from "@formbricks/js";
<CodeBlock language="js">
{`import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
formbricks.setup({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
appUrl: "${webAppUrl}",
});
}`}</CodeBlock>
}`}
</CodeBlock>
<ul className="list-disc text-sm">
<li>
<span className="font-semibold">environmentId :</span>{" "}
@@ -55,21 +57,20 @@ if (typeof window !== "undefined") {
})}
</li>
<li>
<span className="font-semibold">apiHost:</span>{" "}
<span className="font-semibold">appUrl:</span>{" "}
{t("environments.project.app-connection.api_host_description")}
</li>
</ul>
<span className="text-sm text-slate-600">
{t("environments.project.app-connection.if_you_are_planning_to")}
{t("environments.project.app-connection.if_you_are_planning_to")}{" "}
<Link
href="https://formbricks.com//docs/app-surveys/user-identification"
target="blank"
className="underline">
{t("environments.project.app-connection.identifying_your_users")}
</Link>{" "}
{t("environments.project.app-connection.you_also_need_to_pass_a")}{" "}
<span className="font-semibold">userId</span> {t("environments.project.app-connection.to_the")}{" "}
<span className="font-semibold">init</span> {t("environments.project.app-connection.function")}.
{t("environments.project.app-connection.you_can_set_the_user_id_with")}{" "}
<span className="font-semibold">formbricks.setUserId(userId)</span>
</span>
<h4>{t("environments.project.app-connection.step_3")}</h4>
<p>
@@ -128,7 +129,7 @@ if (typeof window !== "undefined") {
</p>
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: "${environmentId}", appUrl: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`}</CodeBlock>
<h4>Step 2: Debug mode</h4>

View File

@@ -7,7 +7,7 @@ import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { truncate } from "@formbricks/lib/utils/strings";

View File

@@ -14,7 +14,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";

View File

@@ -5,7 +5,7 @@ import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountMo
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import React, { useState } from "react";
import { useState } from "react";
import { TUser } from "@formbricks/types/user";
interface RemovedFromOrganizationProps {

View File

@@ -2,7 +2,7 @@
import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator";
import { useTranslate } from "@tolgee/react";
import React, { ReactNode, useMemo } from "react";
import { ReactNode, useMemo } from "react";
import { getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import { headlineToRecall, recallToHeadline } from "@formbricks/lib/utils/recall";
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";

View File

@@ -30,6 +30,7 @@ interface LinkSurveyProps {
IS_FORMBRICKS_CLOUD: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
}
export const LinkSurvey = ({
@@ -48,6 +49,7 @@ export const LinkSurvey = ({
IS_FORMBRICKS_CLOUD,
locale,
isPreview,
contactId,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
@@ -170,7 +172,7 @@ export const LinkSurvey = ({
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
apiHost={webAppUrl}
appUrl={webAppUrl}
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
@@ -198,6 +200,7 @@ export const LinkSurvey = ({
singleUseId={singleUseId}
singleUseResponseId={responseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
/>
</LinkSurveyWrapper>
);

View File

@@ -25,6 +25,7 @@ interface PinScreenProps {
isEmbed: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
}
export const PinScreen = (props: PinScreenProps) => {
@@ -43,6 +44,7 @@ export const PinScreen = (props: PinScreenProps) => {
isEmbed,
locale,
isPreview,
contactId,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -75,7 +77,7 @@ export const PinScreen = (props: PinScreenProps) => {
if (isValidPin) {
setLoading(true);
const response = await validateSurveyPinAction({ surveyId, pin: localPinEntry });
if (response?.data) {
if (response?.data?.survey) {
setSurvey(response.data.survey);
} else {
const errorMessage = getFormattedErrorMessage(response);
@@ -125,6 +127,7 @@ export const PinScreen = (props: PinScreenProps) => {
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
};

View File

@@ -10,7 +10,7 @@ export const SurveyInactive = async ({
status,
surveyClosedMessage,
}: {
status: "paused" | "completed" | "link invalid" | "scheduled";
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted";
surveyClosedMessage?: TSurveyClosedMessage | null;
}) => {
const t = await getTranslate();
@@ -18,12 +18,14 @@ export const SurveyInactive = async ({
paused: <PauseCircleIcon className="h-20 w-20" />,
completed: <CheckCircle2Icon className="h-20 w-20" />,
"link invalid": <HelpCircleIcon className="h-20 w-20" />,
"response submitted": <CheckCircle2Icon className="h-20 w-20" />,
};
const descriptions = {
paused: t("s.paused"),
completed: t("s.completed"),
"link invalid": t("s.link_invalid"),
"response submitted": t("s.response_submitted"),
};
return (
@@ -41,11 +43,13 @@ export const SurveyInactive = async ({
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
{!(status === "completed" && surveyClosedMessage) &&
status !== "link invalid" &&
status !== "response submitted" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
<div>
<Link href="https://formbricks.com">

View File

@@ -0,0 +1,144 @@
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyRendererProps {
survey: TSurvey;
searchParams: {
verify?: string;
lang?: string;
embed?: string;
preview?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
contactId?: string;
isPreview: boolean;
}
export const renderSurvey = async ({
survey,
searchParams,
singleUseId,
singleUseResponse,
contactId,
isPreview,
}: SurveyRendererProps) => {
const locale = await findMatchingLocale();
const langParam = searchParams.lang;
const isEmbed = searchParams.embed === "true";
if (survey.status === "draft" || survey.type !== "link") {
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
if (survey.status !== "inProgress" && !isPreview) {
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
if (survey.isVerifyEmailEnabled) {
const token = searchParams.verify;
if (token) {
const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token);
emailVerificationStatus = emailVerificationDetails.status;
verifiedEmail = emailVerificationDetails.email;
}
}
// get project
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
webAppUrl={WEBAPP_URL}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
}
return (
<LinkSurvey
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
};

View File

@@ -0,0 +1,75 @@
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getExistingContactResponse } from "@/modules/survey/link/lib/response";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface ContactSurveyPageProps {
params: Promise<{
jwt: string;
}>;
searchParams: Promise<{
verify?: string;
lang?: string;
embed?: string;
preview?: string;
}>;
}
export const generateMetadata = async (props: ContactSurveyPageProps): Promise<Metadata> => {
const { jwt } = await props.params;
try {
// Verify and decode the JWT token
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
return {
title: "Survey",
description: "Complete this survey",
};
}
const { surveyId } = result.data;
return getBasicSurveyMetadata(surveyId);
} catch (error) {
// If the token is invalid, we'll return generic metadata
return {
title: "Survey",
description: "Complete this survey",
};
}
};
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const { jwt } = params;
const { preview } = searchParams;
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
return <SurveyInactive status="link invalid" />;
}
const { surveyId, contactId } = result.data;
const existingResponse = await getExistingContactResponse(surveyId, contactId);
if (existingResponse) {
return <SurveyInactive status="response submitted" />;
}
const isPreview = preview === "true";
const survey = await getSurvey(surveyId);
if (!survey) {
notFound();
}
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
});
};

View File

@@ -0,0 +1,200 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import {
getBasicSurveyMetadata,
getBrandColorForURL,
getNameForURL,
getSurveyOpenGraphMetadata,
} from "./metadata-utils";
// Mock dependencies
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/link/lib/project", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
// Mock constants
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: vi.fn(() => false),
WEBAPP_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/styling/constants", () => ({
COLOR_DEFAULTS: {
brandColor: "#00c4b8",
},
}));
describe("Metadata Utils", () => {
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks();
});
describe("getNameForURL", () => {
it("replaces spaces with %20", () => {
const result = getNameForURL("Hello World");
expect(result).toBe("Hello%20World");
});
it("handles strings with no spaces correctly", () => {
const result = getNameForURL("HelloWorld");
expect(result).toBe("HelloWorld");
});
it("handles strings with multiple spaces", () => {
const result = getNameForURL("Hello World Test");
expect(result).toBe("Hello%20%20World%20%20Test");
});
});
describe("getBrandColorForURL", () => {
it("replaces # with %23", () => {
const result = getBrandColorForURL("#ff0000");
expect(result).toBe("%23ff0000");
});
it("handles strings with no # correctly", () => {
const result = getBrandColorForURL("ff0000");
expect(result).toBe("ff0000");
});
});
describe("getBasicSurveyMetadata", () => {
const mockSurveyId = "survey-123";
const mockEnvironmentId = "env-456";
it("returns default metadata when survey is not found", async () => {
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(result).toEqual({
title: "Survey",
description: "Complete this survey",
survey: null,
});
});
it("uses welcome card headline when available", async () => {
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "Welcome Headline",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
expect(result).toEqual({
title: "Welcome Headline | Formbricks",
description: "Welcome Description",
survey: mockSurvey,
});
});
it("falls back to survey name when welcome card is not enabled", async () => {
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: false,
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result).toEqual({
title: "Test Survey | Formbricks",
description: "Complete this survey",
survey: mockSurvey,
});
});
it("adds Formbricks to title when IS_FORMBRICKS_CLOUD is true", async () => {
// Change the mock for this specific test
(IS_FORMBRICKS_CLOUD as unknown as ReturnType<typeof vi.fn>).mockReturnValue(true);
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: false,
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result.title).toBe("Test Survey | Formbricks");
// Reset the mock
(IS_FORMBRICKS_CLOUD as unknown as ReturnType<typeof vi.fn>).mockReturnValue(false);
});
});
describe("getSurveyOpenGraphMetadata", () => {
it("generates correct OpenGraph metadata", () => {
const surveyId = "survey-123";
const surveyName = "Test Survey";
const brandColor = COLOR_DEFAULTS.brandColor.replace("#", "%23");
const encodedName = surveyName.replace(/ /g, "%20");
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result).toEqual({
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
url: `/s/${surveyId}`,
siteName: "",
images: [`/api/v1/og?brandColor=${brandColor}&name=${encodedName}`],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: surveyName,
description: "Thanks a lot for your time 🙏",
images: [`/api/v1/og?brandColor=${brandColor}&name=${encodedName}`],
},
});
});
it("handles survey names with spaces correctly", () => {
const surveyId = "survey-123";
const surveyName = "Test Survey With Spaces";
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result.openGraph?.images?.[0]).toContain("name=Test%20Survey%20With%20Spaces");
});
});
});

View File

@@ -0,0 +1,92 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { Metadata } from "next";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
/**
* Utility function to encode name for URL usage
*/
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
/**
* Utility function to encode brand color for URL usage
*/
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
/**
* Get basic survey metadata (title and description) based on welcome card or survey name
*/
export const getBasicSurveyMetadata = async (surveyId: string) => {
const survey = await getSurvey(surveyId);
// If survey doesn't exist, return default metadata
if (!survey) {
return {
title: "Survey",
description: "Complete this survey",
survey: null,
};
}
const project = await getProjectByEnvironmentId(survey.environmentId);
const welcomeCard = survey.welcomeCard as TSurveyWelcomeCard;
// Set title to either welcome card headline or survey name
let title = "Survey";
if (welcomeCard.enabled && welcomeCard.headline?.default) {
title = welcomeCard.headline.default;
} else {
title = survey.name;
}
// Set description to either welcome card html content or default
let description = "Complete this survey";
if (welcomeCard.enabled && welcomeCard.html?.default) {
description = welcomeCard.html.default;
}
// Add product name in title if it's Formbricks cloud
if (IS_FORMBRICKS_CLOUD) {
title = `${title} | Formbricks`;
} else if (project) {
// Since project name is not available in the returned type, we'll just use a generic name
title = `${title} | Survey`;
}
return {
title,
description,
survey,
};
};
/**
* Generate Open Graph metadata for survey
*/
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => {
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color
const encodedName = getNameForURL(surveyName);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${encodedName}`;
return {
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
url: `/s/${surveyId}`,
siteName: "",
images: [ogImgURL],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: surveyName,
description: "Thanks a lot for your time 🙏",
images: [ogImgURL],
},
};
};

View File

@@ -69,3 +69,35 @@ export const getResponseBySingleUseId = reactCache(
}
)()
);
export const getExistingContactResponse = reactCache(
async (surveyId: string, contactId: string): Promise<Pick<Response, "id" | "finished"> | null> =>
cache(
async () => {
try {
const response = await prisma.response.findFirst({
where: {
surveyId,
contactId,
},
select: {
id: true,
finished: true,
},
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`link-surveys-getExisitingContactResponse-${surveyId}-${contactId}`],
{
tags: [responseCache.tag.bySurveyId(surveyId), responseCache.tag.byContactId(contactId)],
}
)()
);

View File

@@ -1,8 +1,8 @@
import { getSurveyMetadata } from "@/modules/survey/link/lib/survey";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getBrandColorForURL, getNameForURL, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metadata> => {
const survey = await getSurveyMetadata(surveyId);
@@ -13,30 +13,22 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
const brandColor = getBrandColorForURL(survey.styling?.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
const surveyName = getNameForURL(survey.name);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${surveyName}`;
// Use the shared function for creating the base metadata but override with specific OpenGraph data
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, survey.name);
// Override with the custom image URL that uses the survey's brand color
if (baseMetadata.openGraph) {
baseMetadata.openGraph.images = [ogImgURL];
}
if (baseMetadata.twitter) {
baseMetadata.twitter.images = [ogImgURL];
}
return {
title: survey.name,
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: survey.name,
description: "Thanks a lot for your time 🙏",
url: `/s/${survey.id}`,
siteName: "",
images: [ogImgURL],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: survey.name,
description: "Thanks a lot for your time 🙏",
images: [ogImgURL],
},
...baseMetadata,
};
};
const getNameForURL = (url: string) => url.replace(/ /g, "%20");
const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");

View File

@@ -1,21 +1,11 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { getSurvey } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId } from "@/modules/survey/link/lib/response";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import { Response } from "@prisma/client";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { ZId } from "@formbricks/types/common";
interface LinkSurveyPageProps {
@@ -48,33 +38,13 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (!validId.success) {
notFound();
}
const isPreview = searchParams.preview === "true";
const survey = await getSurvey(params.surveyId);
const locale = await findMatchingLocale();
const suId = searchParams.suId;
const langParam = searchParams.lang; //can either be language code or alias
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
const isEmbed = searchParams.embed === "true";
if (!survey || survey.type !== "link" || survey.status === "draft") {
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
if (survey.status !== "inProgress" && !isPreview) {
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
@@ -95,7 +65,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
singleUseId = validatedSingleUseId ?? suId;
}
let singleUseResponse: Pick<Response, "id" | "finished"> | undefined = undefined;
let singleUseResponse;
if (isSingleUseSurvey) {
try {
singleUseResponse = singleUseId
@@ -106,85 +76,11 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
}
}
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
if (survey.isVerifyEmailEnabled) {
const token = searchParams.verify;
if (token) {
const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token);
emailVerificationStatus = emailVerificationDetails.status;
verifiedEmail = emailVerificationDetails.email;
}
}
// get project and person
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
/>
);
}
return (
<LinkSurvey
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
/>
);
return renderSurvey({
survey,
searchParams,
singleUseId,
singleUseResponse,
isPreview,
});
};

View File

@@ -1,7 +1,6 @@
import { Button } from "@/modules/ui/components/button";
import { KeyIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
export type ModalButton = {
text: string;

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "3.4.0",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -115,6 +115,7 @@
"papaparse": "5.4.1",
"posthog-js": "1.200.2",
"prismjs": "1.30.0",
"qr-code-styling": "1.9.1",
"react": "19.0.0",
"react-colorful": "5.6.1",
"react-confetti": "6.1.0",

View File

@@ -10,10 +10,9 @@ const HTML_TEMPLATE = `<head>
var e = document.getElementsByTagName("script")[0];
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
formbricks.setup({
environmentId: "ENVIRONMENT_ID",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
appUrl: "http://localhost:3000",
});
}, 500);
})();

View File

@@ -1,6 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve",
"jsxImportSource": "react",
"paths": {
"@/*": ["./*"],
"@prisma/client/*": ["@formbricks/database/client/*"]

View File

@@ -1,4 +1,5 @@
// vitest.config.ts
import react from "@vitejs/plugin-react";
import { loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
@@ -19,6 +20,7 @@ export default defineConfig({
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: [
"modules/api/v2/**/*.ts",
"modules/api/v2/**/*.tsx",
"modules/auth/lib/**/*.ts",
"modules/signup/lib/**/*.ts",
"modules/ee/whitelabel/email-customization/components/*.tsx",
@@ -26,18 +28,23 @@ export default defineConfig({
"modules/email/emails/survey/follow-up.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"modules/ee/sso/lib/**/*.ts",
"modules/ee/contacts/lib/**/*.ts",
"modules/survey/link/lib/**/*.ts",
"app/(auth)/layout.tsx",
"app/(app)/layout.tsx",
"app/intercom/*.tsx",
],
exclude: [
"**/.next/**",
"**/*.test.*",
"**/*.spec.*",
"**/constants.ts", // Exclude constants files
"**/route.ts", // Exclude route files
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"modules/**/types/**", // Exclude types
"**/*.tsx", // Exclude tsx files
],
},
},
plugins: [tsconfigPaths()],
plugins: [tsconfigPaths(), react()],
});

View File

@@ -18,6 +18,7 @@ services:
redis:
image: redis:7.0.11
command: "redis-server"
ports:
- 6379:6379
volumes:

View File

@@ -69,6 +69,9 @@ x-environment: &environment
# Set the below to your Unsplash API Key for their Survey Backgrounds
# UNSPLASH_ACCESS_KEY:
# Set the below to 0 to disable cron jobs
# DOCKER_CRON_ENABLED: 1
################################################### OPTIONAL (STORAGE) ###################################################
# Set the below to set a custom Upload Directory
@@ -157,6 +160,8 @@ x-environment: &environment
# Set the below to use Redis for Next Caching (default is In-Memory from Next Cache)
# REDIS_URL:
# REDIS_DEFAULT_TTL:
# Set the below to use for Rate Limiting (default us In-Memory LRU Cache)
# REDIS_HTTP_URL:

View File

@@ -21,15 +21,41 @@ This guide explains the settings you need to use to configure SAML with your Ide
**Assertion Encryption:** Unencrypted
**NameID Format:** EmailAddress
**Application username:** email
**Mapping Attributes / Attribute Statements:**
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier) -> id
- Name claim:
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress) -> email
If your IdP has a `name` claim, set the following claims to populate the name field:
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname) -> firstName
| Name | Name Format | Value |
| ---- | ----------- | --------- |
| name | Basic | user.name |
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname) -> lastName
Many IdPs do not have a `name` claim. If not, you can use different claims to populate the name field. The order of precedence is `name` -> **other options** -> `email`.
**Other options:**
| Name | Name Format | Value |
| --------- | ----------- | ------------------------- |
| firstName | Basic | **FIRST_NAME_EQUIVALENT** |
| lastName | Basic | **LAST_NAME_EQUIVALENT** |
Refer to the table below for the different claims you can use for each IdP.
| IdP | FIRST_NAME_EQUIVALENT | LAST_NAME_EQUIVALENT |
| ----------------------------- | -------------------------------- | -------------------------------- |
| Okta | user.firstName | user.lastName |
| Microsoft Entra ID (Azure AD) | user.givenName | user.surname |
| Google Workspace | user.given_name / user.firstName | user.family_name / user.lastName |
| OneLogin | user.FirstName / user.first_name | user.LastName / user.last_name |
| Auth0 | user.given_name | user.family_name |
| JumpCloud | user.firstname | user.lastname |
Above provided claims may differ based on your configuration and the IdP you are using. Please refer to the documentation of your IdP for the correct claims.
### SAML With Okta

View File

@@ -21,7 +21,6 @@ We use Mintlify to maintain our documentation. You can find more information abo
- Document parameters, return types, and potential side effects
- Example:
```typescript
/**
Creates a new user and initializes their preferences
@@ -31,26 +30,23 @@ Creates a new user and initializes their preferences
@throws {ValidationError} If name is invalid
*/
async function createUser(name: string, options: UserOptions): Promise<User> {
// implementation
// implementation
}
```
2. **TypeScript Ignore Comments**
- When using `@ts-ignore` or `@ts-expect-error`, always include a comment explaining why
- Example:
```typescript
// @ts-expect-error -- Required for dynamic function calls
void window.formbricks.init(...args);
void window.formbricks.setup(...args);
```
### API Documentation
1. **API Endpoints**
- All new API endpoints must be documented in the OpenAPI specification
- Include request/response schemas, authentication requirements, and examples
- Document both Client API and Management API endpoints
@@ -63,11 +59,12 @@ void window.formbricks.init(...args);
### Feature Documentation
- All new features must include a feature documentation file
- Document the feature's purpose, usage, and implementation details
- Include code examples and best practices
- All new features must include a feature documentation file
- Document the feature's purpose, usage, and implementation details
- Include code examples and best practices
## Working with Mintlify
We use Mintlify to write our documentation.
### File Structure
@@ -84,7 +81,6 @@ icon: "appropriate-icon"
---
```
2. **Navigation**
- Add new pages to the appropriate section in `docs/mint.json`
- Follow the existing navigation structure
@@ -104,8 +100,8 @@ Important information goes here
</Note>
```
2. **Media and Assets**
- Store images in the appropriate `/images` subdirectory
- Use descriptive alt text for all images
- Optimize images for web delivery
@@ -130,4 +126,4 @@ mintlify dev
- Verify all links and references work
- Ensure proper formatting and rendering
These documentation requirements ensure that our codebase remains maintainable, accessible, and well-documented for both current and future developers.
These documentation requirements ensure that our codebase remains maintainable, accessible, and well-documented for both current and future developers.

View File

@@ -1,5 +1,6 @@
---
title: Testing Methodology
title: "Testing Methodology"
description: "How we test Formbricks to ensure reliability, performance, and high-quality code."
icon: magnifying-glass
---

View File

@@ -1,5 +1,6 @@
---
title: Framework Usage
description: Guidelines on how Formbricks utilizes Next.js, Tailwind CSS, and Prisma ORM for efficient development and performance.
icon: book
---

View File

@@ -141,10 +141,6 @@
"xm-and-surveys/core-features/test-environment"
]
},
{
"group": "Enterprise Features",
"pages": ["xm-and-surveys/enterprise-features/saml-sso"]
},
{
"group": "XM",
"pages": [

View File

@@ -1,5 +1,6 @@
---
title: "Open-Source"
description: "Open-source Experience Management. Free & open source."
icon: "osi"
---

View File

@@ -1,5 +1,6 @@
---
title: "Migration"
description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---
@@ -172,7 +173,7 @@ Thats it! This new process ensures your **Formbricks** setup stays up to date
With **Formbricks 3.0**, we're making changes to ensure long-term sustainability while still supporting open source. While the **Community Edition** has gained [new features](https://formbricks.com/blog/formbricks-3-0), some [advanced capabilities](https://formbricks.com/docs/self-hosting/license) are now part of the **Enterprise Edition**.
⚠️ **No Downgrade Option:** If you upgrade to **3.0** and run the data migration, **you cannot revert to 2.7.2**. If you rely on **SSO, user identification, or cluster support**, either **stay on version 2.7.x** or [reach out](https://formbricks.com/cdn-cgi/l/email-protection#1e7671727f5e78716c737c6c777d756d307d7173) **for a custom quote**.
⚠️ **No Downgrade Option:** If you upgrade to **3.0** and run the data migration, **you cannot revert to 2.7.2**. If you rely on **SSO, user identification, or cluster support**, either **stay on version 2.7.x** or reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions) **for a custom quote**.
</Warning>

View File

@@ -1,5 +1,5 @@
---
title: "SAML SSO - Self-hosted"
title: "SAML SSO"
icon: "user-shield"
description: "Configure SAML Single Sign-On (SSO) for secure enterprise authentication with your Formbricks instance."
---
@@ -71,7 +71,7 @@ To configure SAML SSO in Formbricks, follow these steps:
<Step title="Database Setup">
Configure a dedicated database for SAML by setting the `SAML_DATABASE_URL` environment variable in your `docker-compose.yml` file (e.g., `postgres://postgres:postgres@postgres:5432/formbricks-saml`). If you're using a self-signed certificate for Postgres, include the `sslmode=disable` parameter.
</Step>
<Step title="IdP Application">
Create a SAML application in your IdP by following your provider's instructions([SAML Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers))
</Step>
@@ -79,7 +79,7 @@ To configure SAML SSO in Formbricks, follow these steps:
<Step title="User Provisioning">
Provision users in your IdP and configure access to the IdP SAML app for all your users (who need access to Formbricks).
</Step>
<Step title="Metadata">
Keep the XML metadata from your IdP handy for the next step.
</Step>

View File

@@ -59,9 +59,10 @@ These variables are present inside your machines docker-compose file. Restart
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
| CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | |
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 | | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and well try our best to work out a solution with you.

View File

@@ -131,6 +131,7 @@ Configure Redis by adding the following environment variables to your instances:
```sh env
REDIS_URL=redis://your-redis-host:6379
REDIS_DEFAULT_TTL=86400
REDIS_HTTP_URL=http://your-redis-host:8000
```
@@ -160,6 +161,19 @@ When using S3 in a cluster setup, ensure that:
- The bucket has appropriate CORS settings configured
- IAM roles/users have sufficient permissions for read/write operations
## Disabling Docker Cron Jobs
When running Formbricks in a cluster setup, you should disable the built-in cron jobs in the Docker image to prevent them from running on multiple instances simultaneously. Instead, you should set up cron jobs in your orchestration system (like Kubernetes) to run on a single instance or as separate jobs.
To disable the Docker cron jobs, set the following environment variable:
```sh env
# Disable Docker cron jobs (0 = disabled, 1 = enabled)
DOCKER_CRON_ENABLED=0
```
This will prevent the cron jobs from starting in the Docker container while still allowing all other Formbricks functionality to work normally.
## Kubernetes Setup
Formbricks provides an official Helm chart for deploying the entire cluster stack on Kubernetes. The Helm chart is available in the [Formbricks GitHub repository](https://github.com/formbricks/formbricks/tree/main/helm-chart).
@@ -167,6 +181,7 @@ Formbricks provides an official Helm chart for deploying the entire cluster stac
### Features of the Helm Chart
The Helm chart provides a complete deployment solution that includes:
- Formbricks application with configurable replicas
- PostgreSQL database (with optional HA configuration)
- Redis cluster for caching
@@ -176,12 +191,14 @@ The Helm chart provides a complete deployment solution that includes:
### Installation Steps
1. Add the Formbricks Helm repository:
```sh
helm repo add formbricks https://raw.githubusercontent.com/formbricks/formbricks/main/helm-chart
helm repo update
```
2. Install the chart:
```sh
helm install formbricks formbricks/formbricks
```
@@ -189,6 +206,7 @@ helm install formbricks formbricks/formbricks
### Configuration Options
The Helm chart can be customized using a `values.yaml` file to configure:
- Number of Formbricks replicas
- Resource limits and requests
- Database configuration

View File

@@ -1,43 +0,0 @@
---
title: "SAML SSO"
icon: "user-shield"
description: "How to set up SAML SSO for Formbricks"
---
<Note>This feature is only available with the Formbricks Enterprise plan having a SAML SSO add-on.</Note>
## Overview
Formbricks supports Security Assertion Markup Language (SAML) SSO. We prioritize your ease of access and security by providing robust Single Sign-On (SSO) capabilities.
### Setting up SAML login
<Steps>
<Step title="Create a SAML application with your Identity Provider (IdP)">
Follow the instructions here - [SAML
Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers)
</Step>
<Step title="Configure access to the IdP SAML app">
Ensure that all users who need access to Formbricks have access to the IdP SAML app.
</Step>
<Step title="Retrieve XML metadata from your IdP">
Keep the XML metadata from your IdP accessible, as you will need it later.
</Step>
<Step title="Set the SAML_DATABASE_URL environment variable">
Set the `SAML_DATABASE_URL` environment variable in your `.env` file to a dedicated database for
SAML(e.g., `postgresql://postgres:@localhost:5432/formbricks-saml`). If you're using a self-signed
certificate for Postgres, include the `sslmode=disable` parameter.
</Step>
<Step title="Set the metadata">
Create a file called `connection.xml` in the `apps/web/saml-connection` directory and paste the XML
metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
</Step>
<Step title="Your users can now log into Formbricks using SAML">
Once setup is complete, please restart the Formbricks server and your users can log into Formbricks using SAML.
</Step>
</Steps>
<Note>
We don't support multiple SAML connections yet. You can only have one SAML connection at a time. If you
change the `connection.xml` file, your existing SAML connection will be overwritten.
</Note>

View File

@@ -28,34 +28,27 @@ How to deliver a specific language depends on the survey type (app or link surve
![Formbricks Home](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/survey-languages-from-home.webp)
- Click on the **Edit languages** button, to add a new language to your survey
![Survey Language Settings](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/survey-languague-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 project.
![Add Multiple Languages to your Project](/images/xm-and-surveys/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.
- Now, return to the dashboard to create a new survey or edit an existing one.
![Surveys Home](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/surveys-home.webp)
- In the survey editor, scroll down to the **Multiple Languages** section at the bottom and enable the toggle next to it.
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/enable-multi-lang.webp)
- Choose a **Default Language** for your survey.
<Note>
Changing the default language will reset all the translations you have made
for the survey.
</Note>
<Note>Changing the default language will reset all the translations you have made for the survey.</Note>
1. Now, add the languages from the dropdown that you want to support in your survey.
@@ -69,28 +62,24 @@ You can come back to this page anytime to add more languages or remove existing
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/translate-as-per-language.webp)
1. Once you are done, click on the **Publish** button to save the survey.
## App Surveys Configuration
1. When you initialise the Formbricks SDK for your user, you can pass a `language` attribute 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.
1. 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.
```js javascript
Formbricks.init({
Formbricks.setup({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user_id>",
attributes: {
language: "de", // ISO identifier or Alias set when creating language
},
appUrl: "<app-url>",
});
Formbricks.setLanguage("de"); // ISO identifier or Alias set when creating language
```
<Note>
If a user has a language assigned, a survey has multi-language activate and it
is missing a translation in the language of the user, the survey will not be
displayed.
If a user has a language assigned, a survey has multi-language activate and it is missing a translation in
the language of the user, the survey will not be displayed.
</Note>
1. 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.

View File

@@ -1,5 +1,6 @@
---
title: "Framework Guides"
description: "Easily add the Formbricks App Survey SDK to your app with guides for different frameworks."
icon: "book"
---
@@ -16,14 +17,12 @@ Integrate the **Formbricks App Survey SDK** into your app using multiple options
</Card>
<Card title="Next.js" icon="react" href="#nextjs">
[Natively add us to your Next.js project, with support for both App and Pages
project
[Natively add us to your Next.js project, with support for both App and Pages project
structure.](https://formbricks.com/docs/app-surveys/framework-guides#next-js)
</Card>
<Card title="Vue.js" icon="vuejs" href="#vue-js">
Learn how to use Formbricks' React Native SDK to integrate your surveys into
React Native applications.
Learn how to use Formbricks' React Native SDK to integrate your surveys into React Native applications.
</Card>
<Card title="React Native" icon="react" color="lightblue" href="#react-native">
@@ -47,10 +46,9 @@ All you need to do is copy a `<script>` tag to your HTML head:
<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "https://app.formbricks.com";
var appUrl = "https://app.formbricks.com";
var environmentId = "<your-environment-id>";
var userId = "<your-user-id>"; //optional
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl})},500)}();
</script>
<!-- END Formbricks Surveys -->
```
@@ -60,7 +58,7 @@ All you need to do is copy a `<script>` tag to your HTML head:
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| api-host | string | URL of the hosted Formbricks instance. |
| app-url | string | URL of the hosted Formbricks instance. |
Now, visit the [Validate Your Setup](#validate-your-setup) section to verify your setup!
@@ -88,10 +86,9 @@ Update your `App.js/ts` file to initialize Formbricks.
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
formbricks.setup({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>", //optional
appUrl: "<app-url>",
});
}
@@ -107,7 +104,7 @@ export default App;
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| api-host | string | URL of the hosted Formbricks instance. |
| app-url | string | URL of the hosted Formbricks instance. |
Now, visit the [Validate Your Setup](#validate-your-setup) section to verify your setup!
@@ -147,10 +144,9 @@ export default function FormbricksProvider() {
const searchParams = useSearchParams();
useEffect(() => {
formbricks.init({
formbricks.setup({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>", //optional
appUrl: "<app-url>",
});
}, []);
@@ -192,10 +188,9 @@ import { useEffect } from "react";
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
formbricks.setup({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>", //optional
appUrl: "<app-url>",
});
}
@@ -220,7 +215,7 @@ export default function App({ Component, pageProps }: AppProps) {
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| api-host | string | URL of the hosted Formbricks instance. |
| app-url | string | URL of the hosted Formbricks instance. |
First, initialize the Formbricks SDK to run only on the client side. To track page changes, register the route change event with the Next.js router.
@@ -246,10 +241,9 @@ yarn add @formbricks/js
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
formbricks.setup({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>", //optional
appUrl: "<app-url>",
});
}
@@ -278,7 +272,7 @@ router.afterEach((to, from) => {
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| api-host | string | URL of the hosted Formbricks instance. |
| app-url | string | URL of the hosted Formbricks instance. |
Now, visit the [Validate Your Setup](#validate-your-setup) section to verify your setup!
@@ -306,8 +300,7 @@ import Formbricks from "@formbricks/react-native";
const config = {
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>", // optional
appUrl: "<app-url>",
};
export default function App() {
@@ -325,7 +318,7 @@ export default function App() {
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| api-host | string | URL of the hosted Formbricks instance. |
| app-url | string | URL of the hosted Formbricks instance. |
## Validate your setup

View File

@@ -27,57 +27,44 @@ This method is recommended for applications where users are required to log in a
### Setting User ID
To enable user identification, set the `userId` in the `init()` call of Formbricks. The user will show up in the Formbricks dashboard only if the `userId` is set. Use a unique string, like a database ID or a unique email address. You can also anonymize the identifier, as long as it is unique for each user.
To enable user identification, call the `setUserId` function of Formbricks and pass the user id. The user will show up in the Formbricks dashboard. Use a unique string, like a database ID or a unique email address. You can also anonymize the identifier, as long as it is unique for each user.
```javascript
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user_id>",
});
formbricks.setUserId("<user-id>");
```
### Enhanced Initialization with User Attributes
Set user attributes in Formbricks during initialization along with the `userId`.
```javascript Enhanced Initialization with User Attributes
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user_id>",
attributes: {
// your custom attributes
Plan: "premium",
},
});
```
## Setting Custom User Attributes
Use the `setAttribute` function to set custom attributes for the user (e.g., name, plan).
<Note>
**Note**: the number of different attribute classes (e.g., "Plan,"
"First Name," etc.) is currently limited to 150 attributes per environment.
</Note>
```javascript Setting Custom Attributes
formbricks.setAttribute("Plan","free");
```
```javascript Setting Custom Attributes
formbricks.setAttribute("Plan", "free");
```
The `setAttribute` function works like this:
```javascript Setting Custom Attributes
formbricks.setAttribute("attribute_key", "attribute_value");
```
You can also set multiple attributes at once by passing an object to the `setAttributes` function:
```javascript Setting Multiple Custom Attributes
formbricks.setAttributes({
attribute_key_1: "attribute_value_1",
attribute_key_2: "attribute_value_2",
});
```
<Note>
**Note**: the number of different attribute classes (e.g., "Plan," "First Name," etc.) is currently limited
to 150 attributes per environment.
</Note>
### Logging Out Users
When a user logs out of your webpage, also log them out of Formbricks to prevent activity from being linked to the wrong user. Use the logout function:
```javascript Logging out User
formbricks.logout();
```javascript Logging out User
formbricks.logout();
```

View File

@@ -97,7 +97,7 @@ spec:
protocol: {{ $config.protocol | default "TCP" | quote }}
{{- end }}
{{- end }}
{{- if or .Values.deployment.envFrom (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:

View File

@@ -296,4 +296,4 @@ postgresql:
containerSecurityContext:
enabled: true
runAsUser: 1001
readOnlyRootFilesystem: false
readOnlyRootFilesystem: false

View File

@@ -0,0 +1,195 @@
data "aws_ssm_parameter" "slack_notification_channel" {
name = "/prod/formbricks/slack-webhook-url"
with_decryption = true
}
resource "aws_cloudwatch_log_group" "cloudwatch_cis_benchmark" {
name = "/aws/cis-benchmark-group"
retention_in_days = 365
}
module "notify-slack" {
source = "terraform-aws-modules/notify-slack/aws"
version = "6.6.0"
slack_channel = "kubernetes"
slack_username = "formbricks-cloudwatch"
slack_webhook_url = data.aws_ssm_parameter.slack_notification_channel.value
sns_topic_name = "cloudwatch-alarms"
create_sns_topic = true
}
module "cloudwatch_cis-alarms" {
source = "terraform-aws-modules/cloudwatch/aws//modules/cis-alarms"
version = "5.7.1"
log_group_name = aws_cloudwatch_log_group.cloudwatch_cis_benchmark.name
alarm_actions = [module.notify-slack.slack_topic_arn]
}
locals {
alarms = {
ALB_HTTPCode_Target_5XX_Count = {
alarm_description = "Average API 5XX target group error code count is too high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
statistic = "Sum"
}
ALB_HTTPCode_ELB_5XX_Count = {
alarm_description = "Average API 5XX load balancer error code count is too high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_ELB_5XX_Count"
statistic = "Sum"
}
ALB_TargetResponseTime = {
alarm_description = format("Average API response time is greater than %s", 0.05)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 0.05
period = 60
unit = "Seconds"
namespace = "AWS/ApplicationELB"
metric_name = "TargetResponseTime"
statistic = "Average"
}
ALB_UnHealthyHostCount = {
alarm_description = format("Unhealthy host count is greater than %s", 1)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/ApplicationELB"
metric_name = "UnHealthyHostCount"
statistic = "Minimum"
}
RDS_CPUUtilization = {
alarm_description = format("Average RDS CPU utilization is greater than %s", 80)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 80
period = 60
unit = "Percent"
namespace = "AWS/RDS"
metric_name = "CPUUtilization"
statistic = "Average"
}
RDS_FreeStorageSpace = {
alarm_description = format("Average RDS free storage space is less than %s", 5)
comparison_operator = "LessThanThreshold"
evaluation_periods = 5
threshold = 5
period = 60
unit = "Gigabytes"
namespace = "AWS/RDS"
metric_name = "FreeStorageSpace"
statistic = "Average"
}
RDS_FreeableMemory = {
alarm_description = format("Average RDS freeable memory is less than %s", 100)
comparison_operator = "LessThanThreshold"
evaluation_periods = 5
threshold = 100
period = 60
unit = "Megabytes"
namespace = "AWS/RDS"
metric_name = "FreeableMemory"
statistic = "Average"
}
RDS_DiskQueueDepth = {
alarm_description = format("Average RDS disk queue depth is greater than %s", 1)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/RDS"
metric_name = "DiskQueueDepth"
statistic = "Average"
}
RDS_ReadIOPS = {
alarm_description = format("Average RDS read IOPS is greater than %s", 1000)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1000
period = 60
unit = "Count/Second"
namespace = "AWS/RDS"
metric_name = "ReadIOPS"
statistic = "Average"
}
RDS_WriteIOPS = {
alarm_description = format("Average RDS write IOPS is greater than %s", 1000)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1000
period = 60
unit = "Count/Second"
namespace = "AWS/RDS"
metric_name = "WriteIOPS"
statistic = "Average"
}
SQS_ApproximateAgeOfOldestMessage = {
alarm_description = format("Average SQS approximate age of oldest message is greater than %s", 300)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 300
period = 60
unit = "Seconds"
namespace = "AWS/SQS"
metric_name = "ApproximateAgeOfOldestMessage"
statistic = "Maximum"
}
DynamoDB_ConsumedReadCapacityUnits = {
alarm_description = format("Average DynamoDB consumed read capacity units is greater than %s", 90)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 90
period = 60
unit = "Count"
namespace = "AWS/DynamoDB"
metric_name = "ConsumedReadCapacityUnits"
statistic = "Average"
}
Lambda_Errors = {
alarm_description = format("Average Lambda errors is greater than %s", 1)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/Lambda"
metric_name = "Errors"
statistic = "Sum"
}
}
}
module "metric_alarm" {
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
version = "5.7.1"
for_each = local.alarms
alarm_name = each.key
alarm_description = each.value.alarm_description
comparison_operator = each.value.comparison_operator
evaluation_periods = each.value.evaluation_periods
threshold = each.value.threshold
period = each.value.period
unit = each.value.unit
namespace = each.value.namespace
metric_name = each.value.metric_name
statistic = each.value.statistic
alarm_actions = [module.notify-slack.slack_topic_arn]
}

View File

@@ -1,27 +0,0 @@
data "aws_ssm_parameter" "slack_notification_channel" {
name = "/prod/formbricks/slack-webhook-url"
with_decryption = true
}
resource "aws_cloudwatch_log_group" "cloudwatch_cis_benchmark" {
name = "/aws/cis-benchmark-group"
retention_in_days = 365
}
module "notify-slack" {
source = "terraform-aws-modules/notify-slack/aws"
version = "6.6.0"
slack_channel = "kubernetes"
slack_username = "formbricks-cloudwatch"
slack_webhook_url = data.aws_ssm_parameter.slack_notification_channel.value
sns_topic_name = "cloudwatch-alarms"
create_sns_topic = true
}
module "cloudwatch_cis-alarms" {
source = "terraform-aws-modules/cloudwatch/aws//modules/cis-alarms"
version = "5.7.1"
log_group_name = aws_cloudwatch_log_group.cloudwatch_cis_benchmark.name
alarm_actions = [module.notify-slack.slack_topic_arn]
}

View File

@@ -0,0 +1,70 @@
################################################################################
# ElastiCache Module
################################################################################
resource "random_password" "valkey" {
length = 20
special = false
}
resource "random_password" "valkey_default_user" {
length = 20
special = false
}
module "valkey_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "~> 5.0"
name = "valkey-sg"
description = "Security group for VPC traffic"
vpc_id = module.vpc.vpc_id
ingress_cidr_blocks = [module.vpc.vpc_cidr_block]
ingress_rules = ["redis-tcp"]
tags = local.tags
}
module "elasticache_user_group" {
source = "terraform-aws-modules/elasticache/aws//modules/user-group"
version = "1.4.1"
user_group_id = "${local.name}-valkey"
create_default_user = false
default_user = {
user_id = "formbricks-default"
passwords = [random_password.valkey_default_user.result]
}
users = {
formbricks = {
access_string = "on ~* +@all"
passwords = [random_password.valkey.result]
}
}
engine = "redis"
tags = merge(local.tags, {
terraform-aws-modules = "elasticache"
})
}
module "valkey_serverless" {
source = "terraform-aws-modules/elasticache/aws//modules/serverless-cache"
version = "1.4.1"
engine = "valkey"
cache_name = "${local.name}-valkey-serverless"
cache_usage_limits = {
data_storage = {
maximum = 2
}
ecpu_per_second = {
maximum = 1000
}
}
major_engine_version = 7
subnet_ids = module.vpc.database_subnets
security_group_ids = [
module.valkey_sg.security_group_id
]
user_group_id = module.elasticache_user_group.group_id
}

View File

@@ -106,116 +106,6 @@ module "vpc_vpc-endpoints" {
tags = local.tags
}
################################################################################
# PostgreSQL Serverless v2
################################################################################
data "aws_rds_engine_version" "postgresql" {
engine = "aurora-postgresql"
version = "16.4"
}
resource "random_password" "postgres" {
length = 20
special = false
}
module "rds-aurora" {
source = "terraform-aws-modules/rds-aurora/aws"
version = "9.12.0"
name = "${local.name}-postgres"
engine = data.aws_rds_engine_version.postgresql.engine
engine_mode = "provisioned"
engine_version = data.aws_rds_engine_version.postgresql.version
storage_encrypted = true
master_username = "formbricks"
master_password = random_password.postgres.result
manage_master_user_password = false
vpc_id = module.vpc.vpc_id
db_subnet_group_name = module.vpc.database_subnet_group_name
security_group_rules = {
vpc_ingress = {
cidr_blocks = module.vpc.private_subnets_cidr_blocks
}
}
performance_insights_enabled = true
apply_immediately = true
skip_final_snapshot = true
enable_http_endpoint = true
serverlessv2_scaling_configuration = {
min_capacity = 0
max_capacity = 10
seconds_until_auto_pause = 3600
}
instance_class = "db.serverless"
instances = {
one = {}
}
tags = local.tags
}
################################################################################
# ElastiCache Module
################################################################################
resource "random_password" "valkey" {
length = 20
special = false
}
module "elasticache" {
source = "terraform-aws-modules/elasticache/aws"
version = "1.4.1"
replication_group_id = "${local.name}-valkey"
engine = "valkey"
engine_version = "7.2"
node_type = "cache.m7g.large"
transit_encryption_enabled = true
auth_token = random_password.valkey.result
maintenance_window = "sun:05:00-sun:09:00"
apply_immediately = true
# Security Group
vpc_id = module.vpc.vpc_id
security_group_rules = {
ingress_vpc = {
# Default type is `ingress`
# Default port is based on the default engine port
description = "VPC traffic"
cidr_ipv4 = module.vpc.vpc_cidr_block
}
}
# Subnet Group
subnet_group_name = "${local.name}-valkey"
subnet_group_description = "${title(local.name)} subnet group"
subnet_ids = module.vpc.database_subnets
# Parameter Group
create_parameter_group = true
parameter_group_name = "${local.name}-valkey"
parameter_group_family = "valkey7"
parameter_group_description = "${title(local.name)} parameter group"
parameters = [
{
name = "latency-tracking"
value = "yes"
}
]
tags = local.tags
}
################################################################################
# EKS Module
################################################################################
@@ -671,6 +561,7 @@ resource "helm_release" "formbricks" {
jobs:
survey-status:
schedule: "0 0 * * *"
successfulJobsHistoryLimit: 0
env:
CRON_SECRET:
valueFrom:
@@ -692,6 +583,7 @@ resource "helm_release" "formbricks" {
- 'curl -X POST -H "content-type: application/json" -H "x-api-key: $CRON_SECRET" "$WEBAPP_URL/api/cron/survey-status"'
weekely-summary:
schedule: "0 8 * * 1"
successfulJobsHistoryLimit: 0
env:
CRON_SECRET:
valueFrom:
@@ -713,6 +605,7 @@ resource "helm_release" "formbricks" {
- 'curl -X POST -H "content-type: application/json" -H "x-api-key: $CRON_SECRET" "$WEBAPP_URL/api/cron/weekly-summary"'
ping:
schedule: "0 9 * * *"
successfulJobsHistoryLimit: 0
env:
CRON_SECRET:
valueFrom:

55
infra/terraform/rds.tf Normal file
View File

@@ -0,0 +1,55 @@
################################################################################
# PostgreSQL Serverless v2
################################################################################
data "aws_rds_engine_version" "postgresql" {
engine = "aurora-postgresql"
version = "16.4"
}
resource "random_password" "postgres" {
length = 20
special = false
}
module "rds-aurora" {
source = "terraform-aws-modules/rds-aurora/aws"
version = "9.12.0"
name = "${local.name}-postgres"
engine = data.aws_rds_engine_version.postgresql.engine
engine_mode = "provisioned"
engine_version = data.aws_rds_engine_version.postgresql.version
storage_encrypted = true
master_username = "formbricks"
master_password = random_password.postgres.result
manage_master_user_password = false
vpc_id = module.vpc.vpc_id
db_subnet_group_name = module.vpc.database_subnet_group_name
security_group_rules = {
vpc_ingress = {
cidr_blocks = module.vpc.private_subnets_cidr_blocks
}
}
performance_insights_enabled = true
apply_immediately = true
skip_final_snapshot = true
enable_http_endpoint = true
serverlessv2_scaling_configuration = {
min_capacity = 0
max_capacity = 10
seconds_until_auto_pause = 3600
}
instance_class = "db.serverless"
instances = {
one = {}
}
tags = local.tags
}

View File

@@ -3,12 +3,10 @@ resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
name = "prod/formbricks/secrets"
}
resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
secret_id = aws_secretsmanager_secret.formbricks_app_secrets.id
secret_string = jsonencode({
DATABASE_URL = "postgres://formbricks:${random_password.postgres.result}@${module.rds-aurora.cluster_endpoint}/formbricks"
REDIS_URL = "rediss://:${random_password.valkey.result}@${module.elasticache.replication_group_primary_endpoint_address}:6379"
REDIS_URL = "rediss://formbricks:${random_password.valkey.result}@${module.valkey_serverless.serverless_cache_endpoint[0].address}:6379"
})
}

Some files were not shown because too many files have changed in this diff Show More