Compare commits
37 Commits
release/4.
...
fix/e2e-ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93c35edfd | ||
|
|
b67177ba55 | ||
|
|
6cf1f49c8e | ||
|
|
4afb95b92a | ||
|
|
38089241b4 | ||
|
|
07487d4871 | ||
|
|
fa0879e3a0 | ||
|
|
3733c22a6f | ||
|
|
5e5baa76ab | ||
|
|
2153d2aa16 | ||
|
|
7fa4862fd9 | ||
|
|
411e9a26ee | ||
|
|
eb1349f205 | ||
|
|
5c25f25212 | ||
|
|
6af81e46ee | ||
|
|
7423fc9472 | ||
|
|
1557ffcca1 | ||
|
|
5d53ed76ed | ||
|
|
ebd399e611 | ||
|
|
843110b0d6 | ||
|
|
51babf2f98 | ||
|
|
6bc5f1e168 | ||
|
|
c9016802e7 | ||
|
|
6a49fb4700 | ||
|
|
646921cd37 | ||
|
|
34d3145fcd | ||
|
|
c3c06eb309 | ||
|
|
bf4c6238d5 | ||
|
|
8972ef0fef | ||
|
|
4e59924a5a | ||
|
|
8b28353b79 | ||
|
|
abbc7a065b | ||
|
|
00e8ee27a2 | ||
|
|
379aeba71a | ||
|
|
717adddeae | ||
|
|
41798266a0 | ||
|
|
a93fa8ec76 |
17
.github/workflows/e2e.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
@@ -166,6 +166,12 @@ jobs:
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Run Cache Integration Tests
|
||||
run: |
|
||||
echo "Running cache integration tests with Redis/Valkey..."
|
||||
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
@@ -175,6 +181,12 @@ jobs:
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Disable rate limiting for E2E tests
|
||||
run: |
|
||||
echo "RATE_LIMITING_DISABLED=1" >> .env
|
||||
echo "Rate limiting disabled for E2E tests"
|
||||
shell: bash
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
@@ -216,11 +228,14 @@ jobs:
|
||||
if: env.AZURE_ENABLED == 'true'
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test:e2e
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("PasswordConfirmationModal", () => {
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Password must be at least 8 characters long")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles cancel button click and resets form", async () => {
|
||||
|
||||
1
apps/web/app/api/v2/health/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GET } from "@/modules/api/v2/health/route";
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
// Mocks
|
||||
@@ -14,6 +14,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback) => {
|
||||
callback(mockSentryScope);
|
||||
return mockSentryScope;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
|
||||
@@ -21,6 +25,14 @@ const mockContextualLoggerError = vi.fn();
|
||||
const mockContextualLoggerWarn = vi.fn();
|
||||
const mockContextualLoggerInfo = vi.fn();
|
||||
|
||||
// Mock Sentry scope that can be referenced in tests
|
||||
const mockSentryScope = {
|
||||
setTag: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/logger", () => {
|
||||
const mockWithContextInstance = vi.fn(() => ({
|
||||
error: mockContextualLoggerError,
|
||||
@@ -110,6 +122,12 @@ describe("withV1ApiWrapper", () => {
|
||||
}));
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock Sentry scope calls
|
||||
mockSentryScope.setTag.mockClear();
|
||||
mockSentryScope.setExtra.mockClear();
|
||||
mockSentryScope.setContext.mockClear();
|
||||
mockSentryScope.setLevel.mockClear();
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
@@ -161,10 +179,9 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
@@ -269,10 +286,8 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
@@ -14,11 +19,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
|
||||
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
|
||||
@@ -173,8 +173,21 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
|
||||
logger.withContext(logContext).error("V1 API Error Details");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
|
||||
const err = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err, { extra: { error, correlationId } });
|
||||
// Set correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
|
||||
// If we have an actual error, capture it with full stacktrace
|
||||
// Otherwise, create a generic error with context
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error);
|
||||
} else {
|
||||
scope.setExtra("originalError", error);
|
||||
const genericError = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(genericError);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -114,9 +114,6 @@ export const MAX_FILE_UPLOAD_SIZES = {
|
||||
standard: 1024 * 1024 * 10, // 10MB
|
||||
big: 1024 * 1024 * 1024, // 1GB
|
||||
} as const;
|
||||
// Storage is considered configured if we have the minimum required settings:
|
||||
// - S3_REGION and S3_BUCKET_NAME are always required
|
||||
// - S3_ACCESS_KEY and S3_SECRET_KEY are optional (for IAM role-based authentication)
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
|
||||
|
||||
// Colors for Survey Bg
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"metadata": "Metadaten",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Zugriffskontrolle",
|
||||
"add_api_key": "API-Schlüssel hinzufügen",
|
||||
"api_key": "API-Schlüssel",
|
||||
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
|
||||
"usability_score_name": "System Usability Score Survey (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "Membership not found",
|
||||
"metadata": "Metadata",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Don't worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Access Control",
|
||||
"add_api_key": "Add API Key",
|
||||
"api_key": "API Key",
|
||||
"api_key_copied_to_clipboard": "API key copied to clipboard",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
|
||||
"usability_score_name": "System Usability Score (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"metadata": "Métadonnées",
|
||||
"minimum": "Min",
|
||||
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Contrôle d'accès",
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
"api_key": "Clé API",
|
||||
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
|
||||
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"metadata": "メタデータ",
|
||||
"minimum": "最小",
|
||||
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
"move_down": "下に移動",
|
||||
"move_up": "上に移動",
|
||||
"multiple_languages": "多言語",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "アクセス制御",
|
||||
"add_api_key": "APIキーを追加",
|
||||
"api_key": "APIキー",
|
||||
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
|
||||
"usability_score_name": "システムユーザビリティスコア(SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"metadata": "metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Controle de Acesso",
|
||||
"add_api_key": "Adicionar Chave API",
|
||||
"api_key": "Chave de API",
|
||||
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
|
||||
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"metadata": "Metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Controlo de Acesso",
|
||||
"add_api_key": "Adicionar Chave API",
|
||||
"api_key": "Chave API",
|
||||
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
|
||||
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"metadata": "Metadate",
|
||||
"minimum": "Minim",
|
||||
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Control acces",
|
||||
"add_api_key": "Adaugă Cheie API",
|
||||
"api_key": "Cheie API",
|
||||
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
|
||||
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"metadata": "元数据",
|
||||
"minimum": "最低",
|
||||
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多种 语言",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "访问控制",
|
||||
"add_api_key": "添加 API 密钥",
|
||||
"api_key": "API Key",
|
||||
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
|
||||
"usability_score_name": "系统 可用性 得分 ( SUS )"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"metadata": "元數據",
|
||||
"minimum": "最小值",
|
||||
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多種語言",
|
||||
@@ -750,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "存取控制",
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
"api_key": "API 金鑰",
|
||||
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
|
||||
@@ -2887,4 +2888,4 @@
|
||||
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
|
||||
"usability_score_name": "系統 可用性 分數 (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
|
||||
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
/**
|
||||
* Check if the main database is reachable and responding
|
||||
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
|
||||
*/
|
||||
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
// Simple query to check if database is reachable
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
logger
|
||||
.withContext({
|
||||
component: "health_check",
|
||||
check_type: "main_database",
|
||||
error,
|
||||
})
|
||||
.error("Database health check failed");
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "main_database", issue: "Database health check failed" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the Redis cache is reachable and responding
|
||||
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
|
||||
*/
|
||||
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const cacheServiceResult = await getCacheService();
|
||||
if (!cacheServiceResult.ok) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "cache_database", issue: "Cache service not available" }],
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
|
||||
if (isAvailable) {
|
||||
return ok(true);
|
||||
}
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "cache_database", issue: "Redis not available" }],
|
||||
});
|
||||
} catch (error) {
|
||||
logger
|
||||
.withContext({
|
||||
component: "health_check",
|
||||
check_type: "cache_database",
|
||||
error,
|
||||
})
|
||||
.error("Redis health check failed");
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "cache_database", issue: "Redis health check failed" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform all health checks and return the overall status
|
||||
* Always returns ok() with health status unless the health check endpoint itself fails
|
||||
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
|
||||
*/
|
||||
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
|
||||
|
||||
const healthStatus: OverallHealthStatus = {
|
||||
main_database: databaseResult.ok ? databaseResult.data : false,
|
||||
cache_database: cacheResult.ok ? cacheResult.data : false,
|
||||
};
|
||||
|
||||
// Always return ok() with the health status - individual dependency failures
|
||||
// are reflected in the boolean values
|
||||
return ok(healthStatus);
|
||||
} catch (error) {
|
||||
// Only return err() if the health check endpoint itself fails
|
||||
logger
|
||||
.withContext({
|
||||
component: "health_check",
|
||||
error,
|
||||
})
|
||||
.error("Health check endpoint failed");
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "health", issue: "Failed to perform health checks" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
|
||||
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Health"],
|
||||
summary: "Health Check",
|
||||
description: "Check the health status of critical application dependencies including database and cache.",
|
||||
requestParams: {},
|
||||
operationId: "healthCheck",
|
||||
security: [],
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Health check completed successfully. Check individual dependency status in response data.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZOverallHealthStatus),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const healthPaths = {
|
||||
"/health": {
|
||||
get: healthCheckEndpoint,
|
||||
},
|
||||
};
|
||||
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ErrorCode, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/cache", () => ({
|
||||
getCacheService: vi.fn(),
|
||||
ErrorCode: {
|
||||
RedisConnectionError: "redis_connection_error",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
withContext: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Health Checks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper function to create a mock CacheService
|
||||
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
|
||||
getRedisClient: vi.fn(),
|
||||
withTimeout: vi.fn(),
|
||||
get: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
keys: vi.fn(),
|
||||
withCache: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
tryGetCachedValue: vi.fn(),
|
||||
trySetCache: vi.fn(),
|
||||
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
|
||||
});
|
||||
|
||||
describe("checkDatabaseHealth", () => {
|
||||
test("should return healthy when database query succeeds", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
|
||||
|
||||
const result = await checkDatabaseHealth();
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
|
||||
});
|
||||
|
||||
test("should return unhealthy when database query fails", async () => {
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
|
||||
|
||||
const result = await checkDatabaseHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "main_database", issue: "Database health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle different types of database errors", async () => {
|
||||
const networkError = new Error("ECONNREFUSED");
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
|
||||
|
||||
const result = await checkDatabaseHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "main_database", issue: "Database health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkCacheHealth", () => {
|
||||
test("should return healthy when Redis is available", async () => {
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(getCacheService).toHaveBeenCalled();
|
||||
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return unhealthy when cache service fails to initialize", async () => {
|
||||
const cacheError = { code: ErrorCode.RedisConnectionError };
|
||||
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "cache_database", issue: "Cache service not available" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return unhealthy when Redis is not available", async () => {
|
||||
const mockCacheService = createMockCacheService(false);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
|
||||
}
|
||||
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle Redis availability check exceptions", async () => {
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "cache_database", issue: "Redis health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle cache service initialization exceptions", async () => {
|
||||
const serviceException = new Error("Cache service unavailable");
|
||||
vi.mocked(getCacheService).mockRejectedValue(serviceException);
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "cache_database", issue: "Redis health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should verify isRedisAvailable is called asynchronously", async () => {
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
await checkCacheHealth();
|
||||
|
||||
// Verify the async method was called
|
||||
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("performHealthChecks", () => {
|
||||
test("should return all healthy when both checks pass", async () => {
|
||||
// Mock successful database check
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
|
||||
|
||||
// Mock successful cache check
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: true,
|
||||
cache_database: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return mixed results when only database is healthy", async () => {
|
||||
// Mock successful database check
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
|
||||
|
||||
// Mock failed cache check
|
||||
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: true,
|
||||
cache_database: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return mixed results when only cache is healthy", async () => {
|
||||
// Mock failed database check
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
|
||||
|
||||
// Mock successful cache check
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: false,
|
||||
cache_database: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return all unhealthy when both checks fail", async () => {
|
||||
// Mock failed database check
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
|
||||
|
||||
// Mock failed cache check
|
||||
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: false,
|
||||
cache_database: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should run both checks in parallel", async () => {
|
||||
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
|
||||
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
|
||||
|
||||
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
|
||||
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const startTime = Date.now();
|
||||
await performHealthChecks();
|
||||
const endTime = Date.now();
|
||||
|
||||
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
|
||||
expect(endTime - startTime).toBeLessThan(150);
|
||||
});
|
||||
|
||||
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
|
||||
// Mock a catastrophic failure in Promise.all itself
|
||||
const originalPromiseAll = Promise.all;
|
||||
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
|
||||
}
|
||||
|
||||
// Restore original Promise.all
|
||||
Promise.all = originalPromiseAll;
|
||||
});
|
||||
});
|
||||
});
|
||||
15
apps/web/modules/api/v2/health/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { performHealthChecks } from "./lib/health-checks";
|
||||
|
||||
export const GET = async () => {
|
||||
const healthStatusResult = await performHealthChecks();
|
||||
if (!healthStatusResult.ok) {
|
||||
return responses.serviceUnavailableResponse({
|
||||
details: healthStatusResult.error.details,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse({
|
||||
data: healthStatusResult.data,
|
||||
});
|
||||
};
|
||||
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZOverallHealthStatus = z
|
||||
.object({
|
||||
main_database: z.boolean().openapi({
|
||||
description: "Main database connection status - true if database is reachable and running",
|
||||
example: true,
|
||||
}),
|
||||
cache_database: z.boolean().openapi({
|
||||
description: "Cache database connection status - true if cache database is reachable and running",
|
||||
example: true,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
title: "Health Check Response",
|
||||
description: "Health check status for critical application dependencies",
|
||||
});
|
||||
|
||||
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;
|
||||
@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
|
||||
);
|
||||
};
|
||||
|
||||
const serviceUnavailableResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 503,
|
||||
message: "Service Unavailable",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 503,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
@@ -325,6 +354,7 @@ export const responses = {
|
||||
unprocessableEntityResponse,
|
||||
tooManyRequestsResponse,
|
||||
internalServerErrorResponse,
|
||||
serviceUnavailableResponse,
|
||||
successResponse,
|
||||
createdResponse,
|
||||
multiStatusResponse,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
|
||||
|
||||
const mockRequest = new Request("http://localhost");
|
||||
@@ -12,6 +12,15 @@ mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock SENTRY_DSN constant
|
||||
@@ -232,7 +241,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
@@ -266,7 +275,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
@@ -303,7 +312,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Function is this file can be used in edge runtime functions, like api routes.
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
|
||||
Sentry.captureException(err, {
|
||||
extra: {
|
||||
details: error.details,
|
||||
type: error.type,
|
||||
correlationId,
|
||||
},
|
||||
scope.setExtra("originalError", error);
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
correlationId,
|
||||
error,
|
||||
})
|
||||
.error("API Error Details");
|
||||
.error("API V2 Error Details");
|
||||
};
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
|
||||
// if we don't add this we get build errors with prisma due to type-nesting
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { logApiErrorEdge } from "./utils-edge";
|
||||
|
||||
export const handleApiError = (
|
||||
request: Request,
|
||||
err: ApiErrorResponseV2,
|
||||
auditLog?: ApiAuditLog
|
||||
auditLog?: TApiAuditLog
|
||||
): Response => {
|
||||
logApiError(request, err, auditLog);
|
||||
|
||||
@@ -55,7 +56,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
|
||||
});
|
||||
};
|
||||
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -82,13 +83,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
|
||||
logApiErrorEdge(request, error);
|
||||
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
|
||||
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
|
||||
if (AUDIT_LOG_ENABLED && auditLog) {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
queueAuditEvent({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
|
||||
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
|
||||
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
|
||||
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
|
||||
@@ -35,6 +37,7 @@ const document = createDocument({
|
||||
version: "2.0.0",
|
||||
},
|
||||
paths: {
|
||||
...healthPaths,
|
||||
...rolePaths,
|
||||
...mePaths,
|
||||
...responsePaths,
|
||||
@@ -55,6 +58,10 @@ const document = createDocument({
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Health",
|
||||
description: "Operations for checking critical application dependencies health status.",
|
||||
},
|
||||
{
|
||||
name: "Roles",
|
||||
description: "Operations for managing roles.",
|
||||
@@ -114,6 +121,7 @@ const document = createDocument({
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
health: ZOverallHealthStatus,
|
||||
role: ZRoles,
|
||||
me: ZApiKeyData,
|
||||
response: ZResponse,
|
||||
|
||||
@@ -66,8 +66,21 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Validate password length to prevent CPU DoS attacks
|
||||
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
|
||||
if (credentials.password && credentials.password.length > 128) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Use a control hash when user doesn't exist to maintain constant timing.
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
let user;
|
||||
try {
|
||||
// Perform database lookup
|
||||
user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials?.email,
|
||||
@@ -79,6 +92,12 @@ export const authOptions: NextAuthOptions = {
|
||||
throw Error("Internal server error. Please try again later");
|
||||
}
|
||||
|
||||
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
|
||||
// Use actual hash if user exists, control hash if user doesn't exist
|
||||
const hashToVerify = user?.password || controlHash;
|
||||
const isValid = await verifyPassword(credentials.password, hashToVerify);
|
||||
|
||||
// Now check all conditions after constant-time operations are complete
|
||||
if (!user) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
|
||||
@@ -96,8 +115,6 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Your account is currently inactive. Please contact the organization admin.");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(credentials.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
if (await shouldLogAuthFailure(user.email)) {
|
||||
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
@@ -10,19 +19,13 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
const ZLoginForm = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(128, { message: "Password must be 128 characters or less" }),
|
||||
totpCode: z.string().optional(),
|
||||
backupCode: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const rateLimitConfigs = {
|
||||
// Authentication endpoints - stricter limits for security
|
||||
auth: {
|
||||
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
|
||||
login: { interval: 900, allowedPerInterval: 10, namespace: "auth:login" }, // 10 per 15 minutes
|
||||
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
|
||||
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
|
||||
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
@@ -170,14 +170,13 @@ export const PricingCard = ({
|
||||
|
||||
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
className="flex justify-center bg-[#635bff]">
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon, Trash2Icon } from "lucide-react";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
@@ -37,26 +38,30 @@ export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: Quota
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
<TooltipRenderer tooltipContent={t("common.duplicate")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -24,7 +23,7 @@ import { Switch } from "@/modules/ui/components/switch";
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
@@ -220,10 +219,10 @@ export const AddApiKeyModal = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
|
||||
<DialogTitle className="px-1">{t("environments.project.api_keys.add_api_key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
|
||||
<DialogBody className="space-y-4 overflow-y-auto py-4">
|
||||
<DialogBody className="space-y-4 overflow-y-auto px-1 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
|
||||
<Input
|
||||
@@ -348,43 +347,31 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("environments.project.api_keys.organization_access")}</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.project.api_keys.organization_access_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[auto_100px_100px] gap-4">
|
||||
<div></div>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Read</span>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Write</span>
|
||||
|
||||
{Object.keys(selectedOrganizationAccess).map((key) => (
|
||||
<Fragment key={key}>
|
||||
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={selectedOrganizationAccess[key].read}
|
||||
onCheckedChange={(newVal) =>
|
||||
setSelectedOrganizationAccessValue(key, "read", newVal)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) =>
|
||||
setSelectedOrganizationAccessValue(key, "write", newVal)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<Label>{t("environments.project.api_keys.organization_access")}</Label>
|
||||
{Object.keys(selectedOrganizationAccess).map((key) => (
|
||||
<div key={key} className="mt-2 flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Read</Label>
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={selectedOrganizationAccess[key].read || selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "read", newVal)}
|
||||
disabled={selectedOrganizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Write</Label>
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "write", newVal)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.project.api_keys.organization_access_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
|
||||
@@ -104,6 +103,8 @@ describe("ViewPermissionModal", () => {
|
||||
setOpen: vi.fn(),
|
||||
projects: mockProjects,
|
||||
apiKey: mockApiKey,
|
||||
onSubmit: vi.fn(),
|
||||
isUpdating: false,
|
||||
};
|
||||
|
||||
test("renders the modal with correct title", () => {
|
||||
@@ -154,7 +155,7 @@ describe("ViewPermissionModal", () => {
|
||||
expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled();
|
||||
expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked();
|
||||
expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled();
|
||||
expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked();
|
||||
expect(screen.getByTestId("organization-access-otherAccess-read")).toBeChecked();
|
||||
expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import {
|
||||
TApiKeyUpdateInput,
|
||||
TApiKeyWithEnvironmentPermission,
|
||||
@@ -22,7 +21,7 @@ import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
|
||||
@@ -168,36 +167,28 @@ export const ViewPermissionModal = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label>{t("environments.project.api_keys.organization_access")}</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[auto_100px_100px] gap-4">
|
||||
<div></div>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Read</span>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Write</span>
|
||||
|
||||
{Object.keys(organizationAccess).map((key) => (
|
||||
<Fragment key={key}>
|
||||
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={organizationAccess[key].read}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={organizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{Object.keys(organizationAccess).map((key) => (
|
||||
<div key={key} className="mb-2 flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium">Read</Label>
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={organizationAccess[key].read || organizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium">Write</Label>
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={organizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
describe("hasPermission", () => {
|
||||
const envId = "env1";
|
||||
@@ -83,17 +83,3 @@ describe("hasPermission", () => {
|
||||
expect(hasPermission(permissions, "other", "GET")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationAccessKeyDisplayName", () => {
|
||||
test("returns tolgee string for accessControl", () => {
|
||||
const t = vi.fn((k) => k);
|
||||
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
|
||||
"environments.project.api_keys.access_control"
|
||||
);
|
||||
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
|
||||
});
|
||||
test("returns tolgee string for other keys", () => {
|
||||
const t = vi.fn((k) => k);
|
||||
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
@@ -43,15 +42,6 @@ export const hasPermission = (
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
|
||||
switch (key) {
|
||||
case "accessControl":
|
||||
return t("environments.project.api_keys.access_control");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
export const hasOrganizationAccess = (
|
||||
authentication: TAuthenticationApiKey,
|
||||
accessType: OrganizationAccessType
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
|
||||
@@ -15,20 +29,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ConditionalLogicProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
|
||||
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
|
||||
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorConditionsProps {
|
||||
conditions: TConditionGroup;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
@@ -8,6 +6,8 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { LogicEditor } from "./logic-editor";
|
||||
|
||||
// Mock the subcomponents to isolate the LogicEditor component
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
@@ -11,10 +15,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CopyPlusIcon, TrashIcon } from "lucide-react";
|
||||
import { CopyIcon, Trash2Icon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
@@ -154,7 +154,7 @@ export const FollowUpItem = ({
|
||||
setDeleteFollowUpModalOpen(true);
|
||||
}}
|
||||
aria-label={t("common.delete")}>
|
||||
<TrashIcon className="h-4 w-4 text-slate-500" />
|
||||
<Trash2Icon className="h-4 w-4 text-slate-500" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -167,7 +167,7 @@ export const FollowUpItem = ({
|
||||
duplicateFollowUp();
|
||||
}}
|
||||
aria-label={t("common.duplicate")}>
|
||||
<CopyPlusIcon className="h-4 w-4 text-slate-500" />
|
||||
<CopyIcon className="h-4 w-4 text-slate-500" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { type Response } from "@prisma/client";
|
||||
import { notFound } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
IMPRINT_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
@@ -16,9 +19,6 @@ 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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SurveyRendererProps {
|
||||
survey: TSurvey;
|
||||
@@ -59,7 +59,7 @@ export const renderSurvey = async ({
|
||||
|
||||
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
|
||||
|
||||
if (survey.status !== "inProgress" && !isPreview) {
|
||||
if (survey.status !== "inProgress") {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return (
|
||||
<SurveyInactive
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRightIcon, EllipsisIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
@@ -15,7 +15,10 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500", className)}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500 hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -32,7 +35,7 @@ const BreadcrumbItem = React.forwardRef<
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:outline hover:outline-slate-300",
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
|
||||
isActive && "bg-slate-100 outline outline-slate-300",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
|
||||
className
|
||||
@@ -80,14 +83,15 @@ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span"
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface BadgeContentProps {
|
||||
id: string | number;
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { NoMobileOverlay } from "./index";
|
||||
|
||||
// Mock the tolgee translation
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) =>
|
||||
key === "common.mobile_overlay_text" ? "Please use desktop to access this section" : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("NoMobileOverlay", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders overlay with correct text", () => {
|
||||
test("renders title and paragraphs", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
expect(screen.getByText("Please use desktop to access this section")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "common.mobile_overlay_title" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("common.mobile_overlay_app_works_best_on_desktop")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.mobile_overlay_surveys_look_good")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("has proper z-index for overlay", () => {
|
||||
test("has proper overlay classes (z-index and responsive hide)", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
|
||||
const overlay = document.querySelector("div.fixed");
|
||||
expect(overlay).toBeInTheDocument();
|
||||
expect(overlay).toHaveClass("z-[9999]");
|
||||
});
|
||||
|
||||
test("has responsive layout with sm:hidden class", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
|
||||
expect(overlay).toHaveClass("sm:hidden");
|
||||
});
|
||||
|
||||
test("renders learn more link with correct href", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "common.learn_more" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://formbricks.com/docs/xm-and-surveys/overview");
|
||||
});
|
||||
|
||||
test("stacks icons with maximize centered inside smartphone", () => {
|
||||
const { container } = render(<NoMobileOverlay />);
|
||||
|
||||
const wrapper = container.querySelector("div.relative.h-16.w-16");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
|
||||
const phoneSvg = wrapper?.querySelector("svg.h-16.w-16");
|
||||
expect(phoneSvg).toBeInTheDocument();
|
||||
|
||||
const expandSvg = wrapper?.querySelector("svg.absolute");
|
||||
expect(expandSvg).toBeInTheDocument();
|
||||
expect(expandSvg).toHaveClass("left-1/2", "top-1/3", "-translate-x-1/2", "-translate-y-1/3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { SmartphoneIcon, XIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, Maximize2Icon, SmartphoneIcon } from "lucide-react";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
export const NoMobileOverlay = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center sm:hidden">
|
||||
<div className="relative h-full w-full bg-slate-50"></div>
|
||||
<div className="bg-slate-850 absolute mx-8 flex flex-col items-center gap-6 rounded-lg px-8 py-10 text-center">
|
||||
<XIcon className="absolute top-14 h-8 w-8 text-slate-500" />
|
||||
<SmartphoneIcon className="h-16 w-16 text-slate-500" />
|
||||
<p className="text-slate-500">{t("common.mobile_overlay_text")}</p>
|
||||
<div className="fixed inset-0 z-[9999] sm:hidden">
|
||||
<div className="absolute inset-0 bg-slate-50"></div>
|
||||
<div className="relative mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<div className="relative h-16 w-16">
|
||||
<SmartphoneIcon className="text-muted-foreground h-16 w-16" />
|
||||
<Maximize2Icon className="text-muted-foreground absolute left-1/2 top-1/3 h-5 w-5 -translate-x-1/2 -translate-y-1/3" />
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
{t("common.mobile_overlay_title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("common.mobile_overlay_app_works_best_on_desktop")}
|
||||
</p>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("common.mobile_overlay_surveys_look_good")}
|
||||
</p>
|
||||
<Button variant="default" asChild className="mt-8">
|
||||
<a href="https://formbricks.com/docs/xm-and-surveys/overview">
|
||||
{t("common.learn_more")}
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
402
apps/web/playwright/api/auth/security.spec.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
// Authentication endpoints are hardcoded to avoid import issues
|
||||
|
||||
test.describe("Authentication Security Tests - Vulnerability Prevention", () => {
|
||||
let csrfToken: string;
|
||||
let testUser: { email: string; password: string };
|
||||
|
||||
test.beforeEach(async ({ request, users }) => {
|
||||
// Get CSRF token for authentication requests
|
||||
const csrfResponse = await request.get("/api/auth/csrf");
|
||||
const csrfData = await csrfResponse.json();
|
||||
csrfToken = csrfData.csrfToken;
|
||||
|
||||
// Create a test user for "existing user" scenarios with unique email
|
||||
const uniqueId = Date.now() + Math.random();
|
||||
const userName = "Security Test User";
|
||||
const userEmail = `security-test-${uniqueId}@example.com`;
|
||||
await users.create({
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
});
|
||||
testUser = {
|
||||
email: userEmail,
|
||||
password: userName, // The fixture uses the name as password
|
||||
};
|
||||
});
|
||||
|
||||
test.describe("DoS Protection - Password Length Limits", () => {
|
||||
test("should handle extremely long passwords without crashing", async ({ request }) => {
|
||||
const email = "nonexistent-dos-test@example.com"; // Use non-existent email for DoS test
|
||||
const extremelyLongPassword = "A".repeat(50000); // 50,000 characters
|
||||
|
||||
const start = Date.now();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: email,
|
||||
password: extremelyLongPassword,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
// Should not crash the server (no 500 errors)
|
||||
expect(response.status()).not.toBe(500);
|
||||
|
||||
// Should handle gracefully
|
||||
expect([200, 400, 401, 429]).toContain(response.status());
|
||||
|
||||
logger.info(
|
||||
`Extremely long password (50k chars) processing time: ${responseTime}ms, status: ${response.status()}`
|
||||
);
|
||||
|
||||
// Verify the security fix is working: long passwords should be rejected quickly
|
||||
// In production, this should be much faster, but test environment has overhead
|
||||
if (responseTime < 5000) {
|
||||
logger.info("✅ Long password rejected quickly - DoS protection working");
|
||||
} else {
|
||||
logger.warn("⚠️ Long password took longer than expected - check DoS protection");
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle password at 128 character limit", async ({ request }) => {
|
||||
const email = "nonexistent-limit-test@example.com"; // Use non-existent email for limit test
|
||||
const maxLengthPassword = "A".repeat(128); // Exactly at the 128 character limit
|
||||
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: email,
|
||||
password: maxLengthPassword,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// Should process normally (not rejected for length)
|
||||
expect(response.status()).not.toBe(500);
|
||||
expect([200, 400, 401, 429]).toContain(response.status());
|
||||
|
||||
logger.info(`Max length password (128 chars) status: ${response.status()}`);
|
||||
});
|
||||
|
||||
test("should reject passwords over 128 characters", async ({ request }) => {
|
||||
const email = "nonexistent-overlimit-test@example.com"; // Use non-existent email for over-limit test
|
||||
const overLimitPassword = "A".repeat(10000); // 10,000 characters (over limit)
|
||||
|
||||
const start = Date.now();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: email,
|
||||
password: overLimitPassword,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
// Should not crash
|
||||
expect(response.status()).not.toBe(500);
|
||||
|
||||
logger.info(
|
||||
`Over-limit password (10k chars) processing time: ${responseTime}ms, status: ${response.status()}`
|
||||
);
|
||||
|
||||
// The key security test: verify it doesn't take exponentially longer than shorter passwords
|
||||
// This tests the DoS protection is working
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Timing Attack Prevention - User Enumeration Protection", () => {
|
||||
test("should not reveal user existence through response timing differences", async ({ request }) => {
|
||||
// Test multiple attempts to get reliable timing measurements
|
||||
const attempts = 50;
|
||||
const nonExistentTimes: number[] = [];
|
||||
const existingUserTimes: number[] = [];
|
||||
|
||||
// Test non-existent user timing (multiple attempts for statistical reliability)
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: `nonexistent-timing-${i}@example.com`,
|
||||
password: "somepassword",
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const end = process.hrtime.bigint();
|
||||
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
|
||||
|
||||
nonExistentTimes.push(responseTime);
|
||||
expect(response.status()).not.toBe(500);
|
||||
}
|
||||
|
||||
// Test existing user with wrong password timing (multiple attempts)
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: testUser.email,
|
||||
password: "wrongpassword123",
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const end = process.hrtime.bigint();
|
||||
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
|
||||
|
||||
existingUserTimes.push(responseTime);
|
||||
expect(response.status()).not.toBe(500);
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgNonExistent = nonExistentTimes.reduce((a, b) => a + b, 0) / nonExistentTimes.length;
|
||||
const avgExisting = existingUserTimes.reduce((a, b) => a + b, 0) / existingUserTimes.length;
|
||||
|
||||
// Calculate the timing difference percentage
|
||||
const timingDifference = Math.abs(avgExisting - avgNonExistent);
|
||||
const timingDifferencePercent = (timingDifference / Math.max(avgExisting, avgNonExistent)) * 100;
|
||||
|
||||
logger.info(
|
||||
`Non-existent user avg: ${avgNonExistent.toFixed(2)}ms (${nonExistentTimes.map((t) => t.toFixed(0)).join(", ")})`
|
||||
);
|
||||
logger.info(
|
||||
`Existing user avg: ${avgExisting.toFixed(2)}ms (${existingUserTimes.map((t) => t.toFixed(0)).join(", ")})`
|
||||
);
|
||||
logger.info(
|
||||
`Timing difference: ${timingDifference.toFixed(2)}ms (${timingDifferencePercent.toFixed(1)}%)`
|
||||
);
|
||||
|
||||
// CRITICAL SECURITY TEST: Timing difference should be minimal
|
||||
// A large timing difference could allow attackers to enumerate users
|
||||
// Allow up to 20% difference to account for network/system variance
|
||||
if (timingDifferencePercent > 20) {
|
||||
logger.warn(
|
||||
`⚠️ SECURITY RISK: Timing difference of ${timingDifferencePercent.toFixed(1)}% could allow user enumeration!`
|
||||
);
|
||||
logger.warn(`⚠️ Consider implementing constant-time authentication to prevent timing attacks`);
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ Timing attack protection: Only ${timingDifferencePercent.toFixed(1)}% difference between scenarios`
|
||||
);
|
||||
}
|
||||
|
||||
// Fail the test if timing difference exceeds our security threshold
|
||||
expect(timingDifferencePercent).toBeLessThan(20); // Fail at our actual security threshold
|
||||
});
|
||||
|
||||
test("should return consistent status codes regardless of user existence", async ({ request }) => {
|
||||
const scenarios = [
|
||||
{
|
||||
email: "nonexistent-status@example.com",
|
||||
password: "testpassword",
|
||||
description: "non-existent user",
|
||||
},
|
||||
{ email: testUser.email, password: "wrongpassword", description: "existing user, wrong password" },
|
||||
];
|
||||
|
||||
const results: { scenario: string; status: number }[] = [];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: scenario.email,
|
||||
password: scenario.password,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
scenario: scenario.description,
|
||||
status: response.status(),
|
||||
});
|
||||
|
||||
expect(response.status()).not.toBe(500);
|
||||
}
|
||||
|
||||
// Log results
|
||||
results.forEach(({ scenario, status }) => {
|
||||
logger.info(`Status test - ${scenario}: ${status}`);
|
||||
});
|
||||
|
||||
// CRITICAL: Both scenarios should return the same status code
|
||||
// Different status codes could reveal user existence
|
||||
const statuses = results.map((r) => r.status);
|
||||
const uniqueStatuses = [...new Set(statuses)];
|
||||
|
||||
if (uniqueStatuses.length > 1) {
|
||||
logger.warn(
|
||||
`⚠️ SECURITY RISK: Different status codes (${uniqueStatuses.join(", ")}) could allow user enumeration!`
|
||||
);
|
||||
} else {
|
||||
logger.info(`✅ Status code consistency: Both scenarios return ${statuses[0]}`);
|
||||
}
|
||||
|
||||
expect(uniqueStatuses.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Security Headers and Response Safety", () => {
|
||||
test("should include security headers in responses", async ({ request }) => {
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: "nonexistent-headers-test@example.com",
|
||||
password: "testpassword",
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// Check for important security headers
|
||||
const headers = response.headers();
|
||||
|
||||
// These headers should be present for security
|
||||
expect(headers["x-frame-options"]).toBeDefined();
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff");
|
||||
|
||||
if (headers["strict-transport-security"]) {
|
||||
expect(headers["strict-transport-security"]).toContain("max-age");
|
||||
}
|
||||
if (headers["content-security-policy"]) {
|
||||
expect(headers["content-security-policy"]).toContain("default-src");
|
||||
}
|
||||
|
||||
logger.info("✅ Security headers present in authentication responses");
|
||||
});
|
||||
|
||||
test("should not expose sensitive information in error responses", async ({ request }) => {
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: "nonexistent-disclosure-test@example.com",
|
||||
password: "A".repeat(10000), // Trigger long password handling
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
// Log the actual response for debugging
|
||||
logger.info(`Response status: ${response.status()}`);
|
||||
logger.info(`Response body (first 500 chars): ${responseBody.substring(0, 500)}`);
|
||||
|
||||
// Check if this is an HTML response (which indicates NextAuth.js is returning a page instead of API response)
|
||||
const isHtmlResponse =
|
||||
responseBody.trim().startsWith("<!DOCTYPE html>") || responseBody.includes("<html");
|
||||
|
||||
if (isHtmlResponse) {
|
||||
logger.info(
|
||||
"✅ NextAuth.js returned HTML page instead of API response - this is expected behavior for security"
|
||||
);
|
||||
logger.info("✅ No sensitive technical information exposed in authentication API");
|
||||
return; // Skip the sensitive information check for HTML responses
|
||||
}
|
||||
|
||||
// Only check for sensitive information in actual API responses (JSON/text)
|
||||
const sensitiveTerms = [
|
||||
"password_too_long",
|
||||
"bcrypt",
|
||||
"hash",
|
||||
"redis",
|
||||
"database",
|
||||
"prisma",
|
||||
"stack trace",
|
||||
"rate limit exceeded",
|
||||
"authentication failed",
|
||||
"sql",
|
||||
"query",
|
||||
"connection timeout",
|
||||
"internal error",
|
||||
];
|
||||
|
||||
let foundSensitiveInfo = false;
|
||||
const foundTerms: string[] = [];
|
||||
|
||||
for (const term of sensitiveTerms) {
|
||||
if (responseBody.toLowerCase().includes(term.toLowerCase())) {
|
||||
foundSensitiveInfo = true;
|
||||
foundTerms.push(term);
|
||||
logger.warn(`Found "${term}" in response`);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundSensitiveInfo) {
|
||||
logger.warn(`⚠️ Found sensitive information in response: ${foundTerms.join(", ")}`);
|
||||
logger.warn(`Full response body: ${responseBody}`);
|
||||
} else {
|
||||
logger.info("✅ No sensitive technical information exposed in error responses");
|
||||
}
|
||||
|
||||
// Don't fail the test for generic web responses, only for actual security leaks
|
||||
expect(foundSensitiveInfo).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle malformed requests gracefully", async ({ request }) => {
|
||||
// Test with missing CSRF token
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: "nonexistent-malformed-test@example.com",
|
||||
password: "testpassword",
|
||||
redirect: "false",
|
||||
json: "true",
|
||||
// Missing csrfToken
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// Should handle gracefully, not crash
|
||||
expect(response.status()).not.toBe(500);
|
||||
expect([200, 400, 401, 403, 429]).toContain(response.status());
|
||||
|
||||
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,11 @@ export const SURVEYS_API_URL = `/api/v1/management/surveys`;
|
||||
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
|
||||
export const ROLES_API_URL = `/api/v2/roles`;
|
||||
export const ME_API_URL = `/api/v2/me`;
|
||||
export const HEALTH_API_URL = `/api/v2/health`;
|
||||
|
||||
// Authentication endpoints
|
||||
export const AUTH_CALLBACK_URL = `/api/auth/callback/credentials`;
|
||||
export const AUTH_CSRF_URL = `/api/auth/csrf`;
|
||||
|
||||
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
|
||||
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>
|
||||
|
||||
135
apps/web/playwright/api/health.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { HEALTH_API_URL } from "./constants";
|
||||
|
||||
test.describe("API Tests for Health Endpoint", () => {
|
||||
test("Health check returns 200 with dependency status", async ({ request }) => {
|
||||
try {
|
||||
// Make request to health endpoint (no authentication required)
|
||||
const response = await request.get(HEALTH_API_URL);
|
||||
|
||||
// Should always return 200 if the health check endpoint can execute
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
// Verify response structure
|
||||
expect(responseBody).toHaveProperty("data");
|
||||
expect(responseBody.data).toHaveProperty("main_database");
|
||||
expect(responseBody.data).toHaveProperty("cache_database");
|
||||
|
||||
// Verify data types are boolean
|
||||
expect(typeof responseBody.data.main_database).toBe("boolean");
|
||||
expect(typeof responseBody.data.cache_database).toBe("boolean");
|
||||
|
||||
// Log the health status for debugging
|
||||
logger.info(
|
||||
{
|
||||
main_database: responseBody.data.main_database,
|
||||
cache_database: responseBody.data.cache_database,
|
||||
},
|
||||
"Health check status"
|
||||
);
|
||||
|
||||
// In a healthy system, we expect both to be true
|
||||
// But we don't fail the test if they're false - that's what the health check is for
|
||||
if (!responseBody.data.main_database) {
|
||||
logger.warn("Main database is unhealthy");
|
||||
}
|
||||
|
||||
if (!responseBody.data.cache_database) {
|
||||
logger.warn("Cache database is unhealthy");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during health check API test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test("Health check response time is reasonable", async ({ request }) => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await request.get(HEALTH_API_URL);
|
||||
|
||||
const endTime = Date.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// Health check should respond within 5 seconds
|
||||
expect(responseTime).toBeLessThan(5000);
|
||||
|
||||
logger.info({ responseTime }, "Health check response time");
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during health check performance test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test("Health check is accessible without authentication", async ({ request }) => {
|
||||
try {
|
||||
// Make request without any headers or authentication
|
||||
const response = await request.get(HEALTH_API_URL, {
|
||||
headers: {
|
||||
// Explicitly no x-api-key or other auth headers
|
||||
},
|
||||
});
|
||||
|
||||
// Should be accessible without authentication
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody).toHaveProperty("data");
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during unauthenticated health check test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test("Health check handles CORS properly", async ({ request }) => {
|
||||
try {
|
||||
// Test with OPTIONS request (preflight)
|
||||
const optionsResponse = await request.fetch(HEALTH_API_URL, {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
// OPTIONS should succeed or at least not be a server error
|
||||
expect(optionsResponse.status()).not.toBe(500);
|
||||
|
||||
// Test regular GET request
|
||||
const getResponse = await request.get(HEALTH_API_URL);
|
||||
expect(getResponse.status()).toBe(200);
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during CORS health check test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test("Health check OpenAPI schema compliance", async ({ request }) => {
|
||||
try {
|
||||
const response = await request.get(HEALTH_API_URL);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
// Verify it matches our OpenAPI schema
|
||||
expect(responseBody).toMatchObject({
|
||||
data: {
|
||||
main_database: expect.any(Boolean),
|
||||
cache_database: expect.any(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure no extra properties in the response data
|
||||
const dataKeys = Object.keys(responseBody.data);
|
||||
expect(dataKeys).toHaveLength(2);
|
||||
expect(dataKeys).toContain("main_database");
|
||||
expect(dataKeys).toContain("cache_database");
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during OpenAPI schema compliance test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ Before you proceed, make sure you have the following:
|
||||
Copy and paste the following command into your terminal:
|
||||
|
||||
```bash
|
||||
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/formbricks.sh)"
|
||||
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/formbricks.sh)"
|
||||
```
|
||||
|
||||
The script will prompt you for the following information:
|
||||
|
||||
0
docker/migrate-to-v4.sh
Normal file → Executable file
@@ -5675,7 +5675,7 @@
|
||||
},
|
||||
"/api/v1/management/storage": {
|
||||
"post": {
|
||||
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3 along with a local upload URL.",
|
||||
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3.",
|
||||
"parameters": [
|
||||
{
|
||||
"example": "{{apiKey}}",
|
||||
@@ -5732,8 +5732,15 @@
|
||||
"example": {
|
||||
"data": {
|
||||
"fileUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile--fid--abc123.png",
|
||||
"localUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile.png",
|
||||
"signedUrl": "http://localhost:3000/api/v1/client/cm1ubebtj000614kqe4hs3c67/storage/public",
|
||||
"presignedFields": {
|
||||
"Policy": "base64EncodedPolicy",
|
||||
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
||||
"X-Amz-Credential": "your-credential",
|
||||
"X-Amz-Date": "20250312T000000Z",
|
||||
"X-Amz-Signature": "your-signature",
|
||||
"key": "uploads/public/profile--fid--abc123.png"
|
||||
},
|
||||
"signedUrl": "https://s3.example.com/your-bucket",
|
||||
"updatedFileName": "profile--fid--abc123.png"
|
||||
}
|
||||
},
|
||||
@@ -5745,9 +5752,12 @@
|
||||
"description": "URL where the uploaded file can be accessed.",
|
||||
"type": "string"
|
||||
},
|
||||
"localUrl": {
|
||||
"description": "URL for uploading the file to local storage.",
|
||||
"type": "string"
|
||||
"presignedFields": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Form fields to include in the multipart/form-data POST to S3.",
|
||||
"type": "object"
|
||||
},
|
||||
"signedUrl": {
|
||||
"description": "Signed URL for uploading the file to S3.",
|
||||
@@ -5765,7 +5775,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK - Returns the signed URL, updated file name, and file URL."
|
||||
"description": "OK - Returns the signed URL, presigned fields, updated file name, and file URL."
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
@@ -5829,187 +5839,6 @@
|
||||
"tags": ["Management API - Storage"]
|
||||
}
|
||||
},
|
||||
"/api/v1/management/storage/local": {
|
||||
"post": {
|
||||
"description": "Management API endpoint for uploading public files to local storage. This endpoint requires authentication. File metadata is provided via headers (X-File-Type, X-File-Name, X-Environment-ID, X-Signature, X-UUID, X-Timestamp) and the file is provided as a multipart/form-data file field named \"file\". The \"Content-Type\" header must be set to a valid MIME type.",
|
||||
"parameters": [
|
||||
{
|
||||
"example": "{{apiKey}}",
|
||||
"in": "header",
|
||||
"name": "x-api-key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "MIME type of the file. Must be a valid MIME type.",
|
||||
"in": "header",
|
||||
"name": "X-File-Type",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "URI encoded file name.",
|
||||
"in": "header",
|
||||
"name": "X-File-Name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "ID of the environment.",
|
||||
"in": "header",
|
||||
"name": "X-Environment-ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Signature for verifying the request.",
|
||||
"in": "header",
|
||||
"name": "X-Signature",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Unique identifier for the signed upload.",
|
||||
"in": "header",
|
||||
"name": "X-UUID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Timestamp used for the signature.",
|
||||
"in": "header",
|
||||
"name": "X-Timestamp",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "The file to be uploaded as a valid file object (buffer).",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": {
|
||||
"message": "File uploaded successfully"
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Success message.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK - File uploaded successfully."
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"error": "fileType is required"
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error": {
|
||||
"description": "Detailed error message.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request - Missing required fields, invalid header values, or file issues."
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"error": "Not authenticated"
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error": {
|
||||
"description": "Detailed error message.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized - Authentication failed, invalid signature, or user not authorized."
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"error": "File upload failed"
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error": {
|
||||
"description": "Detailed error message.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error - File upload failed due to server error."
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"description": "Formbricks API Server",
|
||||
"url": "https://app.formbricks.com/api/v1"
|
||||
}
|
||||
],
|
||||
"summary": "Upload Public File to Local Storage",
|
||||
"tags": ["Management API - Storage"]
|
||||
}
|
||||
},
|
||||
"/api/v1/management/surveys": {
|
||||
"get": {
|
||||
"description": "Fetches all existing surveys",
|
||||
|
||||
@@ -7,6 +7,8 @@ servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
description: Formbricks Cloud
|
||||
tags:
|
||||
- name: Health
|
||||
description: Operations for checking critical application dependencies health status.
|
||||
- name: Roles
|
||||
description: Operations for managing roles.
|
||||
- name: Me
|
||||
@@ -391,6 +393,36 @@ paths:
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
description: Formbricks API Server
|
||||
/health:
|
||||
get:
|
||||
tags:
|
||||
- Health
|
||||
summary: Health Check
|
||||
description: Check the health status of critical application dependencies
|
||||
including database and cache.
|
||||
operationId: healthCheck
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Health check completed successfully. Check individual dependency
|
||||
status in response data.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
main_database:
|
||||
type: boolean
|
||||
description: Main database connection status - true if database is reachable and
|
||||
running
|
||||
example: true
|
||||
cache_database:
|
||||
type: boolean
|
||||
description: Cache database connection status - true if cache database is
|
||||
reachable and running
|
||||
example: true
|
||||
title: Health Check Response
|
||||
description: Health check status for critical application dependencies
|
||||
/roles:
|
||||
get:
|
||||
operationId: getRoles
|
||||
@@ -3500,6 +3532,24 @@ components:
|
||||
name: x-api-key
|
||||
description: Use your Formbricks x-api-key to authenticate.
|
||||
schemas:
|
||||
health:
|
||||
type: object
|
||||
properties:
|
||||
main_database:
|
||||
type: boolean
|
||||
description: Main database connection status - true if database is reachable and
|
||||
running
|
||||
example: true
|
||||
cache_database:
|
||||
type: boolean
|
||||
description: Cache database connection status - true if cache database is
|
||||
reachable and running
|
||||
example: true
|
||||
required:
|
||||
- main_database
|
||||
- cache_database
|
||||
title: Health Check Response
|
||||
description: Health check status for critical application dependencies
|
||||
role:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3835,8 +3885,6 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- link
|
||||
- web
|
||||
- website
|
||||
- app
|
||||
description: The type of the survey
|
||||
status:
|
||||
@@ -4346,7 +4394,6 @@ components:
|
||||
- createdBy
|
||||
- environmentId
|
||||
- endings
|
||||
- thankYouCard
|
||||
- hiddenFields
|
||||
- variables
|
||||
- displayOption
|
||||
@@ -4364,7 +4411,6 @@ components:
|
||||
- isSingleResponsePerEmailEnabled
|
||||
- inlineTriggers
|
||||
- isBackButtonHidden
|
||||
- verifyEmail
|
||||
- recaptcha
|
||||
- metadata
|
||||
- displayPercentage
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"light": "#00C4B8",
|
||||
"primary": "#00C4B8"
|
||||
},
|
||||
"errors": {
|
||||
"404": {
|
||||
"redirect": true
|
||||
}
|
||||
},
|
||||
"favicon": "/images/favicon.svg",
|
||||
"footer": {
|
||||
"socials": {
|
||||
@@ -69,13 +74,13 @@
|
||||
"xm-and-surveys/surveys/general-features/multi-language-surveys",
|
||||
"xm-and-surveys/surveys/general-features/partial-submissions",
|
||||
"xm-and-surveys/surveys/general-features/recall",
|
||||
"xm-and-surveys/surveys/general-features/schedule-start-end-dates",
|
||||
"xm-and-surveys/surveys/general-features/metadata",
|
||||
"xm-and-surveys/surveys/general-features/variables",
|
||||
"xm-and-surveys/surveys/general-features/hide-back-button",
|
||||
"xm-and-surveys/surveys/general-features/email-followups",
|
||||
"xm-and-surveys/surveys/general-features/quota-management",
|
||||
"xm-and-surveys/surveys/general-features/spam-protection"
|
||||
"xm-and-surveys/surveys/general-features/spam-protection",
|
||||
"xm-and-surveys/surveys/general-features/tags"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -225,6 +230,7 @@
|
||||
"self-hosting/configuration/custom-ssl",
|
||||
"self-hosting/configuration/environment-variables",
|
||||
"self-hosting/configuration/smtp",
|
||||
"self-hosting/configuration/file-uploads",
|
||||
"self-hosting/configuration/domain-configuration",
|
||||
{
|
||||
"group": "Auth & SSO",
|
||||
@@ -393,447 +399,358 @@
|
||||
"redirects": [
|
||||
{
|
||||
"destination": "/docs/overview/what-is-formbricks",
|
||||
"permanent": true,
|
||||
"source": "/docs/introduction/what-is-formbricks"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/overview/open-source",
|
||||
"permanent": true,
|
||||
"source": "/docs/introduction/why-open-source"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/overview",
|
||||
"permanent": true,
|
||||
"source": "/docs/introduction/how-it-works"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/contact-form",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/contact-form"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/docs-feedback",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/docs-feedback"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/feature-chaser",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/feature-chaser"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/feedback-box",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/feedback-box"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-email-content",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/improve-email-content"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/interview-prompt",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/interview-prompt"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/cancel-subscription",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/cancel-subscription"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/pmf-survey",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/pmf-survey"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/quiz-time",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/quiz-time"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-trial-cr",
|
||||
"permanent": true,
|
||||
"source": "/docs/best-practices/improve-trial-cr"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/quickstart",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/quickstart"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/add-image-or-video-question"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/conditional-logic",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/conditional-logic"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/overwrite-styling",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/overwrite-styling"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/data-prefilling"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/embed-surveys",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/embed-surveys"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/hidden-fields"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/limit-submissions"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/market-research-panel",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/market-research-panel"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/multi-language-surveys"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/partial-submissions",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/partial-submissions"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/pin-protected-surveys"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/recall",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/recall"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/single-use-links"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/source-tracking"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/schedule-start-end-dates",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/schedule-start-end-dates"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/start-at-question"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/metadata"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/variables",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/variables"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/verify-email-before-survey"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/global/add-image-or-video-question"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/consent",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/consent"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/statement-cta",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/statement-cta"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/airtable"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/zapier",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/zapier"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/wordpress",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/wordpress"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/slack",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/slack"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/n8n",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/n8n"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/notion",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/notion"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/google-sheets"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/quickstart",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/quickstart"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/address",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/address"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/framework-guides"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/activepieces",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/activepieces"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/user-management",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/access-roles"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/styling-theme",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/styling-theme"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/email-customization",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/email-customization"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/setup/one-click",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/one-click"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/configuration/custom-ssl",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/custom-ssl"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/setup/docker",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/docker"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/setup/cluster-setup",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/cluster-setup"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/advanced/migration",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/migration-guide"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/configuration/integrations",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/integrations"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/advanced/license",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/license"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/advanced/rate-limiting",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/rate-limiting"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/self-hosting/setup/cluster-setup",
|
||||
"permanent": true,
|
||||
"source": "/docs/self-hosting/kubernetes"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/development/overview",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/overview"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/js-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/react-native-in-app-surveys"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/api-reference/rest-api",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/rest-api"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/webhooks"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/development/contribution/contribution",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/contributing/get-started"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/actions",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/actions"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/advanced-targeting"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/user-identification",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/user-identification"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/recontact"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/show-survey-to-percent-of-users",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/global/show-survey-to-percent-of-users"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/global/metadata"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/api-reference",
|
||||
"permanent": true,
|
||||
"source": "/docs/api-docs"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/development/troubleshooting",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/contributing/troubleshooting"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/file-upload",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/file-upload"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/select-picture",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/picture-selection"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/rating",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/rating"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/date",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/date"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/schedule-a-meeting",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/schedule"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/free-text",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/free-text"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/select-single",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/single-select"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/select-multiple",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/multiple-select"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/matrix",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/matrix"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/make",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/make"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/integrations/overview",
|
||||
"permanent": true,
|
||||
"source": "/docs/developer-docs/integrations/overview"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/global/hidden-fields"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/global/limit-submissions"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/core-features/question-type/net-promoter-score",
|
||||
"permanent": true,
|
||||
"source": "/docs/core-features/global/question-type/net-promoter-score"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/data-prefilling"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
|
||||
"permanent": true,
|
||||
"source": "/docs/app-surveys/global/multi-language-surveys"
|
||||
}
|
||||
],
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 119 KiB |
@@ -6,23 +6,37 @@ icon: "key"
|
||||
|
||||
To unlock Formbricks Enterprise Edition features, you need to activate your Enterprise License Key. Follow these steps to activate your license:
|
||||
|
||||
## 1. Set the License Key
|
||||
<Steps>
|
||||
<Step title="Set the License Key">
|
||||
Add your Enterprise License Key as an environment variable in your deployment:
|
||||
|
||||
Add your Enterprise License Key as an environment variable in your deployment:
|
||||
```bash
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
```
|
||||
|
||||
```bash
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
```
|
||||
- Add your Enterprise License Key after `ENTERPRISE_LICENSE_KEY=` with the key you received from Formbricks.
|
||||
- How you set environment variables depends on your deployment (Docker, Kubernetes, .env file, etc.).
|
||||
</Step>
|
||||
|
||||
- Add your Enterprise License Key after `ENTERPRISE_LICENSE_KEY=` with the key you received from Formbricks.
|
||||
- How you set environment variables depends on your deployment (Docker, Kubernetes, .env file, etc.).
|
||||
<Step title="Restart Your Instance">
|
||||
After setting the environment variable, **restart your Formbricks instance** to apply the changes.
|
||||
</Step>
|
||||
|
||||
## 2. Restart Your Instance
|
||||
<Step title="Verify License Activation">
|
||||
To verify if your license is active, visit `Organization Settings` -> `Enterprise License` to check the confirmation screen.
|
||||
</Step>
|
||||
|
||||
After setting the environment variable, **restart your Formbricks instance** to apply the changes.
|
||||
<Step title="Ensure License Server is Reachable">
|
||||
Your Formbricks instance performs a daily license check to verify your Enterprise License. If you're deploying Formbricks behind a firewall or have network restrictions, you need to whitelist the following:
|
||||
|
||||
## 3. Verify License Activation
|
||||
To verify if your license is active, visit `Organization Settings` -> `Enterprise License` to check the confirmation screen.
|
||||
- **Domain**: `ee.formbricks.com`
|
||||
- **URL**: `https://ee.formbricks.com/api/licenses/check`
|
||||
- **Protocol**: HTTPS (Port 443)
|
||||
- **Method**: POST
|
||||
- **Frequency**: Every 24 hours
|
||||
|
||||
### Troubleshooting
|
||||
Your server needs to be able to reach the Formbricks License Server. In case you're deploying Formbricks behind a firewall, please reach out to hola@formbricks.com for more info.
|
||||
The license check includes a 3-day grace period. If the check fails temporarily, your instance will continue using cached license information for up to 3 days.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
If you have questions or need assistance with network configuration, please reach out to hola@formbricks.com.
|
||||
@@ -4,10 +4,10 @@ description: "License for Formbricks"
|
||||
icon: "file-certificate"
|
||||
---
|
||||
|
||||
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free).
|
||||
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free%3F).
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
@@ -18,21 +18,17 @@ Additional to the AGPLv3 licensed Formbricks core, the Formbricks repository con
|
||||
|
||||
## When do I need an Enterprise License?
|
||||
|
||||
| | Community Edition | Enterprise License |
|
||||
| | Community Edition | Enterprise Edition |
|
||||
| ------------------------------------------------------------- | ----------------- | ------------------ |
|
||||
| Self-host for commercial purposes | ✅ | No license needed |
|
||||
| Fork codebase, make changes, release under AGPLv3 | ✅ | No license needed |
|
||||
| Self-host for commercial purposes | ✅ | ✅ |
|
||||
| Fork codebase, make changes, release under AGPLv3 | ✅ | ✅ |
|
||||
| Fork codebase, make changes, **keep private** | ❌ | ✅ |
|
||||
| Unlimited responses | ✅ | No license needed |
|
||||
| Unlimited surveys | ✅ | No license needed |
|
||||
| Unlimited users | ✅ | No license needed |
|
||||
| Unlimited responses | ✅ | Pay per response |
|
||||
| Unlimited surveys | ✅ | ✅ |
|
||||
| Unlimited users | ✅ | ✅ |
|
||||
| Projects | 3 | Unlimited |
|
||||
| Use any of the other [free features](#what-features-are-free) | ✅ | No license needed |
|
||||
| Remove branding | ❌ | ✅ |
|
||||
| SSO | ❌ | ✅ |
|
||||
| Contacts & Targeting | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Use any of the [paid features](#what-features-are-free) | ❌ | ✅ |
|
||||
| Use all [free features](#what-features-are-free%3F) | ✅ | ✅ |
|
||||
| Use [paid features](#what-features-are-free%3F) | ❌ | Pay per feature |
|
||||
|
||||
## Open Core Licensing
|
||||
|
||||
@@ -45,14 +41,14 @@ The Formbricks core application is licensed under the [AGPLv3 Open Source Licens
|
||||
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://formbricks.com/enterprise-license?source=docs) to unlock it.
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
|
||||
## White-Labeling Formbricks and Other Licensing Needs
|
||||
|
||||
We currently do not offer Formbricks white-labeled. Any other needs? [Send us an email](mailto:hola@formbricks.com).
|
||||
We offer Formbricks white-labeled in some cases. [Please send us an email with a project description and we'll get back to you.](mailto:hola@formbricks.com).
|
||||
|
||||
## Why charge for Enterprise Features?
|
||||
|
||||
@@ -63,6 +59,8 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Feature | Community Edition | Enterprise Edition |
|
||||
| ---------------------------------------------- | ----------------- | ------------------ |
|
||||
| Unlimited surveys | ✅ | ✅ |
|
||||
| Full API Access | ✅ | ✅ |
|
||||
| All SDKs | ✅ | ✅ |
|
||||
| Website & App surveys | ✅ | ✅ |
|
||||
| Link surveys | ✅ | ✅ |
|
||||
| Email embedded surveys | ✅ | ✅ |
|
||||
@@ -77,18 +75,18 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Hidden fields | ✅ | ✅ |
|
||||
| Single-use links | ✅ | ✅ |
|
||||
| Pin-protected surveys | ✅ | ✅ |
|
||||
| Full API Access | ✅ | ✅ |
|
||||
| All SDKs | ✅ | ✅ |
|
||||
| Webhooks | ✅ | ✅ |
|
||||
| Email follow-ups | ✅ | ✅ |
|
||||
| Multi-language UI | ✅ | ✅ |
|
||||
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
|
||||
| Domain Split Configuration | ✅ | ✅ |
|
||||
| Cluster Hosting via Formbricks Helm Chart | ✅ | ✅ |
|
||||
| Hide "Powered by Formbricks" | ❌ | ✅ |
|
||||
| Whitelabel email follow-ups | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Contact management & segments | ❌ | ✅ |
|
||||
| Multi-language surveys | ❌ | ✅ |
|
||||
| Quota Management | ❌ | ✅ |
|
||||
| Audit Logs | ❌ | ✅ |
|
||||
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
|
||||
| SAML SSO | ❌ | ✅ |
|
||||
@@ -98,4 +96,4 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| White-glove onboarding | ❌ | ✅ |
|
||||
| Support SLAs | ❌ | ✅ |
|
||||
|
||||
**Any more questions?** [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
|
||||
Questions? [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
|
||||
|
||||
@@ -4,6 +4,149 @@ description: "Formbricks Self-hosted version migration"
|
||||
icon: "arrow-right"
|
||||
---
|
||||
|
||||
## v4.0
|
||||
|
||||
<Warning>
|
||||
**Important: Migration Required**
|
||||
|
||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||
</Warning>
|
||||
|
||||
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
||||
|
||||
### What's New in Formbricks 4.0
|
||||
|
||||
**🚀 New Enterprise Features:**
|
||||
- **Quotas Management**: Advanced quota controls for enterprise users
|
||||
|
||||
**🏗️ Technical Foundation Improvements:**
|
||||
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
||||
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
||||
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
||||
- **Future-Ready Architecture**: Standardized infrastructure components for upcoming features
|
||||
|
||||
### What This Means for Your Self-Hosting Setup
|
||||
|
||||
These improvements in Formbricks 4.0 also make some infrastructure requirements mandatory going forward:
|
||||
|
||||
- **Redis** for caching
|
||||
- **MinIO or S3-compatible storage** for file uploads
|
||||
|
||||
These services are already included in the updated one-click setup for self-hosters, but existing users need to upgrade their setup. More information on this below.
|
||||
|
||||
### Why We Made These Changes
|
||||
|
||||
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
||||
|
||||
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||
- **Advanced features** that require sophisticated caching and file processing
|
||||
- **Better performance** through optimized, dedicated services
|
||||
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
||||
|
||||
We believe this is the only path forward to build the comprehensive Survey and Experience Management software we're aiming for.
|
||||
|
||||
### Migration Steps for v4.0
|
||||
|
||||
Additional migration steps are needed if you are using a self-hosted Formbricks setup that uses either local file storage (not S3-compatible file storage) or doesn't already use a Redis cache.
|
||||
|
||||
### One-Click Setup
|
||||
|
||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||
|
||||
```bash
|
||||
# Download the latest script
|
||||
curl -fsSL -o migrate-to-v4.sh \
|
||||
https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/migrate-to-v4.sh
|
||||
|
||||
# Make it executable
|
||||
chmod +x migrate-to-v4.sh
|
||||
|
||||
# Launch the guided migration
|
||||
./migrate-to-v4.sh
|
||||
```
|
||||
|
||||
This script guides you through the steps for the infrastructure migration and does the following:
|
||||
- Adds a Redis service to your setup and configures it
|
||||
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
||||
- Pulls the latest Formbricks image and updates your instance
|
||||
|
||||
|
||||
### Manual Setup
|
||||
|
||||
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
||||
|
||||
#### Redis
|
||||
|
||||
Formbricks 4.0 requires a Redis instance to work properly. Please add a Redis instance to your Docker setup, your K8s infrastructure, or however you are hosting Formbricks at the moment. Formbricks works with the latest versions of Redis as well as Valkey.
|
||||
|
||||
You need to configure the `REDIS_URL` environment variable and point it to your Redis instance.
|
||||
|
||||
#### S3-compatible storage
|
||||
|
||||
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
|
||||
|
||||
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
||||
- AWS S3
|
||||
- Digital Ocean Spaces
|
||||
- Hetzner Object Storage
|
||||
- Custom MinIO server
|
||||
|
||||
Please make sure to set up a storage bucket with one of these solutions and then link it to Formbricks using the following environment variables:
|
||||
|
||||
```
|
||||
S3_ACCESS_KEY: your-access-key
|
||||
S3_SECRET_KEY: your-secret-key
|
||||
S3_REGION: us-east-1
|
||||
S3_BUCKET_NAME: formbricks-uploads
|
||||
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
||||
```
|
||||
#### Upgrade Process
|
||||
|
||||
**1. Backup your Database**
|
||||
|
||||
**Critical Step**: Create a complete database backup before proceeding. Formbricks 4.0 will automatically remove unused database tables and fields during startup.
|
||||
|
||||
```bash
|
||||
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.0_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you run into "**No such container**", use `docker ps` to find your container name,
|
||||
e.g. `formbricks_postgres_1`.
|
||||
</Info>
|
||||
|
||||
**2. Upgrade to Formbricks 4.0**
|
||||
|
||||
Pull the latest Docker images and restart the setup (example for docker-compose):
|
||||
|
||||
```bash
|
||||
# Pull the latest version
|
||||
docker compose pull
|
||||
|
||||
# Stop the current instance
|
||||
docker compose down
|
||||
|
||||
# Start with Formbricks 4.0
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**3. Automatic Database Migration**
|
||||
|
||||
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
||||
- Detect and apply required database schema updates
|
||||
- Remove unused database tables and fields
|
||||
- Optimize the database structure for better performance
|
||||
|
||||
No manual intervention is required for the database migration.
|
||||
|
||||
**4. Verify Your Upgrade**
|
||||
|
||||
- Access your Formbricks instance at the same URL as before
|
||||
- Test file uploads to ensure S3/MinIO integration works correctly
|
||||
- Verify that existing surveys and data are intact
|
||||
- Check that previously uploaded files are accessible
|
||||
|
||||
### v3.3
|
||||
|
||||
<Info>
|
||||
@@ -185,7 +328,7 @@ This major release brings a better approach to **data migrations**.
|
||||
|
||||
### Steps to Migrate
|
||||
|
||||
This guide is for users **self-hosting** Formbricks with the **one-click setup**. If you're using a different setup, you may need to adjust the commands.
|
||||
This guide is for users **self-hosting** Formbricks with the **one-click setup**. If you're using a different setup, you might adjust the commands.
|
||||
|
||||
-  Navigate to the Formbricks Directory
|
||||
|
||||
|
||||
319
docs/self-hosting/configuration/file-uploads.mdx
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
title: "File Uploads Configuration"
|
||||
description: "Configure file storage for survey images, file uploads, and project assets in your self-hosted Formbricks instance"
|
||||
icon: "upload"
|
||||
---
|
||||
|
||||
Formbricks requires S3-compatible storage for file uploads. You can use external cloud storage services or the bundled MinIO option for a self-hosted solution.
|
||||
|
||||
## Why Configure File Uploads?
|
||||
|
||||
Setting up file storage enables important features in Formbricks, including:
|
||||
|
||||
- Adding images to surveys (questions, backgrounds, logos)
|
||||
- 'File Upload' and 'Picture Selection' question types
|
||||
- Project logos and branding
|
||||
- Custom organization logos in emails
|
||||
- Survey background images from uploads
|
||||
|
||||
<Warning>
|
||||
If file uploads are not configured, the above features will be disabled and users won't be able to upload
|
||||
files or images.
|
||||
</Warning>
|
||||
|
||||
## Storage Options
|
||||
|
||||
Formbricks supports S3-compatible storage with two main configurations:
|
||||
|
||||
### 1. External S3-Compatible Storage
|
||||
|
||||
Use cloud storage services for production deployments:
|
||||
|
||||
- **AWS S3** (Amazon Web Services)
|
||||
- **DigitalOcean Spaces**
|
||||
- **Wasabi**
|
||||
- **StorJ**
|
||||
- Any S3-compatible storage service
|
||||
|
||||
### 2. Bundled MinIO Storage (Self-Hosted)
|
||||
|
||||
<Warning>
|
||||
**Important**: MinIO requires a dedicated subdomain to function properly. You must configure a subdomain
|
||||
like `files.yourdomain.com` that points to your server. MinIO will not work without this subdomain setup.
|
||||
</Warning>
|
||||
|
||||
MinIO provides a self-hosted S3-compatible storage solution that runs alongside Formbricks. This option:
|
||||
|
||||
- Runs in a Docker container alongside Formbricks
|
||||
- Provides full S3 API compatibility
|
||||
- Requires minimal additional configuration
|
||||
|
||||
## Configuration Methods
|
||||
|
||||
### Option 1: One-Click Setup Script
|
||||
|
||||
When using the Formbricks installation script, you'll be prompted to configure file uploads:
|
||||
|
||||
```bash
|
||||
📁 Do you want to configure file uploads?
|
||||
If you skip this, the following features will be disabled:
|
||||
- Adding images to surveys (e.g., in questions or as background)
|
||||
- 'File Upload' and 'Picture Selection' question types
|
||||
- Project logos
|
||||
- Custom organization logo in emails
|
||||
Configure file uploads now? [Y/n] y
|
||||
```
|
||||
|
||||
#### External S3-Compatible Storage
|
||||
|
||||
Choose this option for AWS S3, DigitalOcean Spaces, or other cloud providers:
|
||||
|
||||
```bash
|
||||
🗄️ Do you want to use an external S3-compatible storage (AWS S3/DO Spaces/etc.)? [y/N] y
|
||||
🔧 Enter S3 configuration (leave Endpoint empty for AWS S3):
|
||||
S3 Access Key: your_access_key
|
||||
S3 Secret Key: your_secret_key
|
||||
S3 Region (e.g., us-east-1): us-east-1
|
||||
S3 Bucket Name: your-bucket-name
|
||||
S3 Endpoint URL (leave empty if you are using AWS S3): https://your-endpoint.com
|
||||
```
|
||||
|
||||
#### Bundled MinIO Storage
|
||||
|
||||
Choose this option for a self-hosted S3-compatible storage that runs alongside Formbricks:
|
||||
|
||||
<Note>
|
||||
**Critical Requirement**: Before proceeding, ensure you have configured a subdomain (e.g.,
|
||||
`files.yourdomain.com`) that points to your server's IP address. MinIO will not function without this
|
||||
subdomain setup.
|
||||
</Note>
|
||||
|
||||
```bash
|
||||
🗄️ Do you want to use an external S3-compatible storage (AWS S3/DO Spaces/etc.)? [y/N] n
|
||||
🔗 Enter the files subdomain for object storage (e.g., files.yourdomain.com): files.yourdomain.com
|
||||
```
|
||||
|
||||
The script will automatically:
|
||||
|
||||
- Generate secure MinIO credentials
|
||||
- Create the storage bucket
|
||||
- Configure SSL certificates for the files subdomain
|
||||
- Configure Traefik routing for the subdomain
|
||||
|
||||
### Option 2: Manual Environment Variables
|
||||
|
||||
Add the following environment variables to your `docker-compose.yml` or `.env` file:
|
||||
|
||||
#### For S3-Compatible Storage
|
||||
|
||||
```bash
|
||||
# S3 Storage Configuration
|
||||
S3_ACCESS_KEY=your_access_key
|
||||
S3_SECRET_KEY=your_secret_key
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET_NAME=your-bucket-name
|
||||
|
||||
# Optional: For third-party S3-compatible services (leave empty for AWS S3)
|
||||
S3_ENDPOINT_URL=https://your-endpoint.com
|
||||
|
||||
# Enable path-style URLs for third-party services (1 for enabled, 0 for disabled)
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
<Note>
|
||||
<strong>AWS S3 vs. third‑party S3:</strong> When using AWS S3 directly, leave `S3_ENDPOINT_URL` unset and
|
||||
set `S3_FORCE_PATH_STYLE=0` (or omit). For most third‑party S3‑compatible providers (e.g., MinIO,
|
||||
DigitalOcean Spaces, Wasabi, Storj), you typically must set `S3_ENDPOINT_URL` to the provider's endpoint and
|
||||
set `S3_FORCE_PATH_STYLE=1`.
|
||||
</Note>
|
||||
|
||||
## Provider-Specific Examples
|
||||
|
||||
### AWS S3
|
||||
|
||||
```bash
|
||||
S3_ACCESS_KEY=AKIA1234567890EXAMPLE
|
||||
S3_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET_NAME=my-formbricks-uploads
|
||||
# S3_ENDPOINT_URL is not needed for AWS S3
|
||||
# S3_FORCE_PATH_STYLE=0
|
||||
```
|
||||
|
||||
### DigitalOcean Spaces
|
||||
|
||||
```bash
|
||||
S3_ACCESS_KEY=your_spaces_key
|
||||
S3_SECRET_KEY=your_spaces_secret
|
||||
S3_REGION=nyc3
|
||||
S3_BUCKET_NAME=my-formbricks-space
|
||||
S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
### MinIO (Self-Hosted)
|
||||
|
||||
```bash
|
||||
S3_ACCESS_KEY=minio_access_key
|
||||
S3_SECRET_KEY=minio_secret_key
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET_NAME=formbricks-uploads
|
||||
S3_ENDPOINT_URL=https://files.yourdomain.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
### Compatibility requirement: S3 POST Object support
|
||||
|
||||
Formbricks uses the S3 [POST Object](https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html)
|
||||
operation (presigned POST) for uploads. Your object storage provider must support this operation. Providers
|
||||
that do not implement POST Object are not compatible with Formbricks uploads. For example, Backblaze B2's
|
||||
S3‑compatible API currently does not support POST Object and therefore will not work with Formbricks file
|
||||
uploads.
|
||||
|
||||
## Bundled MinIO Setup
|
||||
|
||||
When using the bundled MinIO option through the setup script, you get:
|
||||
|
||||
### Automatic Configuration
|
||||
|
||||
- **Storage Service**: MinIO running in a Docker container
|
||||
- **Credentials**: Auto-generated secure access keys
|
||||
- **Bucket**: Automatically created `formbricks-uploads` bucket
|
||||
- **SSL**: Automatic certificate generation for the files subdomain
|
||||
|
||||
### Access Information
|
||||
|
||||
After setup, you'll see:
|
||||
|
||||
```bash
|
||||
🗄️ MinIO Storage Setup Complete:
|
||||
• S3 API: https://files.yourdomain.com
|
||||
• Access Key: formbricks-a1b2c3d4
|
||||
• Bucket: formbricks-uploads (✅ automatically created)
|
||||
```
|
||||
|
||||
### DNS Requirements
|
||||
|
||||
<Warning>
|
||||
**Critical for MinIO**: The subdomain configuration is mandatory for MinIO to function. Without proper
|
||||
subdomain DNS setup, MinIO will fail to work entirely.
|
||||
</Warning>
|
||||
|
||||
For the bundled MinIO setup, ensure:
|
||||
|
||||
1. **Main domain**: `yourdomain.com` points to your server IP
|
||||
2. **Files subdomain**: `files.yourdomain.com` points to your server IP (this is required for MinIO to work)
|
||||
3. **Firewall**: Ports 80 and 443 are open in your server's firewall
|
||||
4. **DNS propagation**: Allow time for DNS changes to propagate globally
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
For manual setup, update your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
formbricks:
|
||||
image: ghcr.io/formbricks/formbricks:latest
|
||||
environment:
|
||||
# ... other environment variables ...
|
||||
|
||||
# S3 Storage Configuration
|
||||
S3_ACCESS_KEY: your_access_key
|
||||
S3_SECRET_KEY: your_secret_key
|
||||
S3_REGION: us-east-1
|
||||
S3_BUCKET_NAME: your-bucket-name
|
||||
S3_ENDPOINT_URL: https://your-endpoint.com # Optional
|
||||
S3_FORCE_PATH_STYLE: 1 # For third-party services
|
||||
volumes:
|
||||
- uploads:/home/nextjs/apps/web/uploads/ # Still needed for temporary files
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### S3 Bucket Permissions
|
||||
|
||||
Configure your S3 bucket with a least-privileged policy:
|
||||
|
||||
1. **Scoped Public Read Access**: Only allow public read access to specific prefixes where needed
|
||||
2. **Restricted Write Access**: Only your Formbricks instance should be able to upload files
|
||||
3. **CORS Configuration**: Allow requests from your Formbricks domain
|
||||
|
||||
Example least-privileged S3 bucket policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"Statement": [
|
||||
{
|
||||
"Action": "s3:GetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Resource": "arn:aws:s3:::your-bucket-name/uploads/public/*",
|
||||
"Sid": "PublicReadForPublicUploads"
|
||||
},
|
||||
{
|
||||
"Action": ["s3:PutObject", "s3:PutObjectAcl"],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::123456789012:user/formbricks-service"
|
||||
},
|
||||
"Resource": "arn:aws:s3:::your-bucket-name/*",
|
||||
"Sid": "AllowFormbricksWrite"
|
||||
}
|
||||
],
|
||||
"Version": "2012-10-17"
|
||||
}
|
||||
```
|
||||
|
||||
### MinIO Security
|
||||
|
||||
When using bundled MinIO:
|
||||
|
||||
- Credentials are auto-generated and secure
|
||||
- Access is restricted through Traefik proxy
|
||||
- CORS is automatically configured
|
||||
- Rate limiting is applied to prevent abuse
|
||||
- A bucket policy with the least privileges is applied to the bucket
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Files not uploading:**
|
||||
|
||||
1. Check that S3 credentials are correct
|
||||
2. Verify bucket exists and is accessible
|
||||
3. Ensure bucket permissions allow uploads from your server
|
||||
4. Check network connectivity to S3 endpoint
|
||||
|
||||
**Images not displaying in surveys:**
|
||||
|
||||
1. Verify bucket has public read access
|
||||
2. Check CORS configuration allows requests from your domain
|
||||
3. Ensure S3_ENDPOINT_URL is correctly set for third-party services
|
||||
|
||||
**MinIO not starting:**
|
||||
|
||||
1. **Verify subdomain DNS**: Ensure `files.yourdomain.com` points to your server IP (this is the most common issue)
|
||||
2. **Check DNS propagation**: Use tools like `nslookup` or `dig` to verify DNS resolution
|
||||
3. **Verify ports**: Ensure ports 80 and 443 are open in your firewall
|
||||
4. **SSL certificate**: Check that SSL certificate generation completed successfully
|
||||
5. **Container logs**: Check Docker container logs: `docker compose logs minio`
|
||||
|
||||
### Testing Your Configuration
|
||||
|
||||
To test if file uploads are working:
|
||||
|
||||
1. **Admin Panel**: Try uploading a project logo in the project settings
|
||||
2. **Survey Editor**: Attempt to add a background image to a survey
|
||||
3. **Question Types**: Create a 'File Upload' or 'Picture Selection' question
|
||||
4. **Check Logs**: Monitor container logs for any storage-related errors
|
||||
|
||||
```bash
|
||||
# Check Formbricks logs
|
||||
docker compose logs formbricks
|
||||
|
||||
# Check MinIO logs (if using bundled MinIO)
|
||||
docker compose logs minio
|
||||
```
|
||||
|
||||
For additional help, join the conversation on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
|
||||
@@ -20,8 +20,6 @@ Running Formbricks as a cluster of multiple instances offers several key advanta
|
||||
|
||||
To run Formbricks in a cluster setup, you'll need:
|
||||
|
||||
- Enterprise Edition license key
|
||||
|
||||
- Shared PostgreSQL database
|
||||
|
||||
- Shared Redis cache for session management and caching
|
||||
@@ -120,7 +118,9 @@ graph TD
|
||||
|
||||
## Redis Configuration
|
||||
|
||||
<Note>Redis is required for Formbricks to function. The application will not start without a Redis URL configured.</Note>
|
||||
<Note>
|
||||
Redis is required for Formbricks to function. The application will not start without a Redis URL configured.
|
||||
</Note>
|
||||
|
||||
Configure Redis by adding the following **required** environment variable to your instances:
|
||||
|
||||
@@ -133,13 +133,11 @@ REDIS_URL=redis://your-redis-host:6379
|
||||
Configure S3 storage by adding the following environment variables to your instances:
|
||||
|
||||
```sh env
|
||||
# Required
|
||||
S3_BUCKET_NAME=your-bucket-name
|
||||
|
||||
# Optional - if not provided, AWS SDK will use defaults (us-east-1) or auto-detect
|
||||
# Required for file uploads in serverless environments
|
||||
S3_ACCESS_KEY=your-access-key
|
||||
S3_SECRET_KEY=your-secret-key
|
||||
S3_REGION=your-region
|
||||
S3_BUCKET_NAME=your-bucket-name
|
||||
|
||||
# For S3-compatible storage (e.g., StorJ, MinIO)
|
||||
# Leave empty for Amazon S3
|
||||
|
||||
@@ -11,7 +11,8 @@ The image is pre-built and requires minimal setup—just download it and start t
|
||||
Make sure Docker and Docker Compose are installed on your system. These are usually included in tools like Docker Desktop and Rancher Desktop.
|
||||
|
||||
<Note>
|
||||
`docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
|
||||
`docker compose` without the hyphen is now the primary method of using docker-compose, according to the
|
||||
Docker documentation.
|
||||
</Note>
|
||||
|
||||
## Start
|
||||
@@ -29,7 +30,7 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
Get the docker-compose file from the Formbricks repository by running:
|
||||
|
||||
```bash
|
||||
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/main/docker/docker-compose.yml
|
||||
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/docker-compose.yml
|
||||
```
|
||||
|
||||
1. **Generate NextAuth Secret**
|
||||
@@ -64,21 +65,21 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
sed -i '' "s/ENCRYPTION_KEY:.*/ENCRYPTION_KEY: $(openssl rand -hex 32)/" docker-compose.yml
|
||||
```
|
||||
|
||||
1. **Generate Cron Secret**
|
||||
1. **Generate Cron Secret**
|
||||
|
||||
You require a Cron secret to secure API access for running cron jobs. Run one of the commands below based on your operating system:
|
||||
You require a Cron secret to secure API access for running cron jobs. Run one of the commands below based on your operating system:
|
||||
|
||||
For Linux:
|
||||
For Linux:
|
||||
|
||||
```bash
|
||||
sed -i "/CRON_SECRET:$/s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
|
||||
```
|
||||
```bash
|
||||
sed -i "/CRON_SECRET:$/s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
|
||||
```
|
||||
|
||||
For macOS:
|
||||
For macOS:
|
||||
|
||||
```bash
|
||||
sed -i '' "s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
|
||||
```
|
||||
```bash
|
||||
sed -i '' "s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
|
||||
```
|
||||
|
||||
1. **Start the Docker Setup**
|
||||
|
||||
|
||||
@@ -9,32 +9,34 @@ icon: "rocket"
|
||||
If you’re looking to quickly set up a production instance of Formbricks on an Ubuntu server, this guide is for you. Using a convenient shell script, you can install everything—including Docker, Postgres DB, and an SSL certificate—in just a few steps. The script takes care of all the dependencies and configuration for your server, making the process smooth and simple.
|
||||
|
||||
<Note>
|
||||
This setup uses **Traefik** as a **reverse proxy**, essential for directing incoming traffic to the correct container and enabling secure internet access to Formbricks. Traefik is chosen for its simplicity and automatic SSL management via Let’s Encrypt.
|
||||
This setup uses **Traefik** as a **reverse proxy**, essential for directing incoming traffic to the correct
|
||||
container and enabling secure internet access to Formbricks. Traefik is chosen for its simplicity and
|
||||
automatic SSL management via Let’s Encrypt.
|
||||
</Note>
|
||||
|
||||
For other operating systems or a more customized installation, please refer to the advanced installation guide with [Docker](/self-hosting/setup/docker).
|
||||
|
||||
### Requirements
|
||||
|
||||
* An Ubuntu Virtual Machine with SSH access.
|
||||
- An Ubuntu Virtual Machine with SSH access.
|
||||
|
||||
* A custom domain with an **A record** pointing to your server.
|
||||
- A custom domain with an **A record** pointing to your server.
|
||||
|
||||
* Ports **80** and **443** are open in your VM's Security Group, allowing Traefik to create an SSL certificate.
|
||||
- Ports **80** and **443** are open in your VM's Security Group, allowing Traefik to create an SSL certificate.
|
||||
|
||||
### Deployment
|
||||
|
||||
Run this command in your terminal:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/formbricks.sh -o formbricks.sh && chmod +x formbricks.sh && ./formbricks.sh install
|
||||
curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/formbricks.sh -o formbricks.sh && chmod +x formbricks.sh && ./formbricks.sh install
|
||||
```
|
||||
|
||||
### Script Prompts
|
||||
|
||||
During installation, the script will prompt you to provide some details:
|
||||
|
||||
* **Overwriting Docker GPG Keys**:
|
||||
- **Overwriting Docker GPG Keys**:
|
||||
If Docker GPG keys already exist, the script will ask whether you want to overwrite them.
|
||||
|
||||
```
|
||||
@@ -50,7 +52,7 @@ During installation, the script will prompt you to provide some details:
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N)
|
||||
```
|
||||
|
||||
* **Domain Name**:
|
||||
- **Domain Name**:
|
||||
Enter the domain name where you’ll host Formbricks. The domain will be used to generate an SSL certificate. Do not include the protocol (http/https).
|
||||
|
||||
```
|
||||
@@ -74,7 +76,7 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
```
|
||||
|
||||
* **HTTPS Certificate Setup**:
|
||||
- **HTTPS Certificate Setup**:
|
||||
The script will ask if you’d like to create an HTTPS certificate for your domain. Enter `Y` to proceed (highly recommended for secure access).
|
||||
|
||||
```
|
||||
@@ -100,7 +102,7 @@ my.hosted.url.com
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
```
|
||||
|
||||
* **DNS Setup Prompt**: Ensure that your domain's DNS is correctly configured and ports 80 and 443 are open. Confirm this by entering `Y`. This step is crucial for proper SSL certificate issuance and secure server access.
|
||||
- **DNS Setup Prompt**: Ensure that your domain's DNS is correctly configured and ports 80 and 443 are open. Confirm this by entering `Y`. This step is crucial for proper SSL certificate issuance and secure server access.
|
||||
|
||||
```
|
||||
🚀 Executing default step of installing Formbricks
|
||||
@@ -127,7 +129,7 @@ Y
|
||||
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
|
||||
```
|
||||
|
||||
* **Email Address for SSL Certificate**:
|
||||
- **Email Address for SSL Certificate**:
|
||||
Provide an email address to register the SSL certificate. Notifications regarding the certificate will be sent to this address.
|
||||
|
||||
```
|
||||
@@ -157,7 +159,7 @@ Y
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
```
|
||||
|
||||
* **Enforce HTTPS with HSTS**:
|
||||
- **Enforce HTTPS with HSTS**:
|
||||
Enabling HTTP Strict Transport Security (HSTS) ensures all communication with your server is encrypted. It’s a recommended best practice. Enter `Y` to enforce HTTPS.
|
||||
|
||||
```
|
||||
@@ -189,7 +191,7 @@ docs@formbricks.com
|
||||
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
|
||||
```
|
||||
|
||||
* **Email Service Setup Prompt**: The script will ask if you want to set up the email service. Enter `Y` to proceed.(default is `N`). You can skip this step if you don't want to set up the email service. You will still be able to use Formbricks without setting up the email service.
|
||||
- **Email Service Setup Prompt**: The script will ask if you want to set up the email service. Enter `Y` to proceed.(default is `N`). You can skip this step if you don't want to set up the email service. You will still be able to use Formbricks without setting up the email service.
|
||||
|
||||
```
|
||||
🚀 Executing default step of installing Formbricks
|
||||
@@ -267,7 +269,7 @@ Y
|
||||
🚙 Updating docker-compose.yml with your custom inputs...
|
||||
🚗 NEXTAUTH_SECRET updated successfully!
|
||||
🚗 ENCRYPTION_KEY updated successfully!
|
||||
🚗 CRON_SECRET updated successfully!
|
||||
🚗 CRON_SECRET updated successfully!
|
||||
|
||||
[+] Running 4/4
|
||||
✔ Network formbricks_default Created 0.2s
|
||||
@@ -344,13 +346,13 @@ If you encounter any issues, you can check the logs of the containers with:
|
||||
|
||||
If you encounter any issues, consider the following steps:
|
||||
|
||||
* **Inbound Rules**: Make sure you have added inbound rules for Port 80 and 443 in your VM's Security Group.
|
||||
- **Inbound Rules**: Make sure you have added inbound rules for Port 80 and 443 in your VM's Security Group.
|
||||
|
||||
* **A Record**: Verify that you have set up an A record for your domain, pointing to your VM's IP address.
|
||||
- **A Record**: Verify that you have set up an A record for your domain, pointing to your VM's IP address.
|
||||
|
||||
* **Check Docker Instances**: Run `docker ps` to check the status of the Docker instances.
|
||||
- **Check Docker Instances**: Run `docker ps` to check the status of the Docker instances.
|
||||
|
||||
* **Check Formbricks Logs**: Run `cd formbricks && docker compose logs` to check the logs of the Formbricks stack.
|
||||
- **Check Formbricks Logs**: Run `cd formbricks && docker compose logs` to check the logs of the Formbricks stack.
|
||||
|
||||
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
|
||||
@@ -4,14 +4,16 @@ description: "Branding the emails that are sent to your respondents."
|
||||
icon: "envelope"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosting Requirements**: Uploading custom organization logos for emails requires file upload storage
|
||||
to be configured. If you're self-hosting Formbricks, make sure to [configure file
|
||||
uploads](/self-hosting/configuration/file-uploads) before using this feature.
|
||||
</Note>
|
||||
|
||||
Email branding is a white-label feature that allows you to customize the email that is sent to your users. You can upload a logo of your company and use it in the email.
|
||||
|
||||
<Note>
|
||||
Email branding is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
<Info>
|
||||
Only the Owner and Managers of the organization can modify the logo.
|
||||
</Info>
|
||||
<Note>Email branding is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).</Note>
|
||||
<Info>Only the Owner and Managers of the organization can modify the logo.</Info>
|
||||
|
||||
## How to upload a logo
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ description: "A step-by-step guide to integrate Airtable with Formbricks Cloud."
|
||||
The Airtable integration allows you to automatically send responses to an Airtable of your choice.
|
||||
|
||||
<Note>
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted instance.
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow
|
||||
the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted
|
||||
instance.
|
||||
</Note>
|
||||
|
||||
## Formbricks Cloud
|
||||
|
||||
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Airtable integration.
|
||||
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Airtable` card.
|
||||
|
||||

|
||||
|
||||
@@ -28,7 +30,8 @@ The Airtable integration allows you to automatically send responses to an Airtab
|
||||

|
||||
|
||||
<Note>
|
||||
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Airtable base with atleast one table in the Airtable account you integrated.
|
||||
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Airtable
|
||||
base with atleast one table in the Airtable account you integrated.
|
||||
</Note>
|
||||
|
||||
1. Now click on the "Link New Table" button to link an Airtable with Formbricks and a modal will open up.
|
||||
@@ -61,4 +64,4 @@ To remove the integration with Airtable,
|
||||
|
||||

|
||||
|
||||
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
|
||||
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
---
|
||||
title: "Google Sheets"
|
||||
description:
|
||||
"The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice."
|
||||
description: "The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice."
|
||||
---
|
||||
|
||||
<Note>
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted instance.
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow
|
||||
the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted
|
||||
instance.
|
||||
</Note>
|
||||
|
||||
## Connect Google Sheets
|
||||
|
||||
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Google Sheets integration.
|
||||
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Google Sheets` card.
|
||||
|
||||

|
||||
|
||||
@@ -25,7 +26,8 @@ description:
|
||||

|
||||
|
||||
<Note>
|
||||
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Google Sheet in the Google account you integrated.
|
||||
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Google
|
||||
Sheet in the Google account you integrated.
|
||||
</Note>
|
||||
|
||||
1. Now click on the "Link New Sheet" button to link a Google Sheet with Formbricks and a modal will open up.
|
||||
@@ -58,11 +60,11 @@ To remove the integration with Google Account,
|
||||
|
||||
## What info do you need?
|
||||
|
||||
* Your **Email ID** for authentication (We use this to identify you)
|
||||
- Your **Email ID** for authentication (We use this to identify you)
|
||||
|
||||
* Your **Google Sheets Names and IDs** (We fetch this to list and show you the options of choosing a sheet to integrate with)
|
||||
- Your **Google Sheets Names and IDs** (We fetch this to list and show you the options of choosing a sheet to integrate with)
|
||||
|
||||
* Write access to **selected Google Sheet** (The google sheet you choose to integrate it with, we write survey responses to it)
|
||||
- Write access to **selected Google Sheet** (The google sheet you choose to integrate it with, we write survey responses to it)
|
||||
|
||||
For the above, we ask for:
|
||||
|
||||
@@ -72,4 +74,4 @@ For the above, we ask for:
|
||||
|
||||
<Note>We store as little personal information as possible.</Note>
|
||||
|
||||
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
|
||||
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
---
|
||||
title: "Notion"
|
||||
description:
|
||||
"The notion integration allows you to automatically send responses to a Notion database of your choice."
|
||||
description: "The notion integration allows you to automatically send responses to a Notion database of your choice."
|
||||
---
|
||||
|
||||
<Note>
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted instance.
|
||||
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow
|
||||
the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted
|
||||
instance.
|
||||
</Note>
|
||||
|
||||
## Formbricks Cloud
|
||||
|
||||
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Notion integration.
|
||||
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Notion` card.
|
||||
|
||||

|
||||
|
||||
@@ -25,8 +26,8 @@ description:
|
||||

|
||||
|
||||
<Note>
|
||||
Before the next step, make sure that you have a Formbricks Survey with at
|
||||
least one question and a Notion database in the Notion account you integrated.
|
||||
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Notion
|
||||
database in the Notion account you integrated.
|
||||
</Note>
|
||||
|
||||
1. Now click on the "Link New Database" button to link a Notion database with Formbricks and a modal will open up.
|
||||
@@ -57,17 +58,17 @@ Enabling the Notion Integration in a self-hosted environment requires a setup us
|
||||
|
||||
5. Now provide it the details such as requested. Under **Redirect URIs** field:
|
||||
|
||||
* If you are running formbricks locally, you can enter `http://localhost:3000/api/v1/integrations/notion/callback`.
|
||||
- If you are running formbricks locally, you can enter `http://localhost:3000/api/v1/integrations/notion/callback`.
|
||||
|
||||
* Or, you can enter `https://<your-public-facing-url>/api/v1/integrations/notion/callback`
|
||||
- Or, you can enter `https://<your-public-facing-url>/api/v1/integrations/notion/callback`
|
||||
|
||||
6. Once you've filled all the necessary details, click on **Submit**.
|
||||
|
||||
7. A screen will appear which will have **Client ID** and **Client secret**. Copy them and set them as the environment variables in your Formbricks instance as:
|
||||
|
||||
* `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID
|
||||
- `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID
|
||||
|
||||
* `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret
|
||||
- `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret
|
||||
|
||||
Voila! You have successfully enabled the Notion integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks Cloud](#formbricks-cloud) section to link a Notion database with Formbricks.
|
||||
|
||||
@@ -85,4 +86,4 @@ To remove the integration with Slack Workspace,
|
||||
|
||||

|
||||
|
||||
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
|
||||
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
|
||||
|
||||
@@ -10,7 +10,7 @@ description:
|
||||
|
||||
## Formbricks Cloud
|
||||
|
||||
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Slack integration.
|
||||
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Slack` card.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
|
||||
## **Creating Webhooks via UI**
|
||||
|
||||
- **Log in to Formbricks**
|
||||
Navigate to the **Integrations** Tab after logging in.
|
||||
and click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab.
|
||||
|
||||

|
||||

|
||||
|
||||
- Click on **Manage Webhooks** & then **Add Webhook** button:
|
||||
|
||||
@@ -58,133 +58,130 @@ Example of Response Created webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"webhookId": "webhookId",
|
||||
"event": "responseCreated",
|
||||
"data": {
|
||||
"id": "responseId",
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"updatedAt": "2025-07-24T07:47:29.507Z",
|
||||
"surveyId": "surveyId",
|
||||
"displayId": "displayId",
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"finished": false,
|
||||
"endingId": null,
|
||||
"data": {
|
||||
"q1": "clicked"
|
||||
},
|
||||
"variables": {},
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684
|
||||
},
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"os": "macOS",
|
||||
"device": "desktop"
|
||||
},
|
||||
"country": "DE"
|
||||
},
|
||||
"singleUseId": null,
|
||||
"language": "en"
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:29.507Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseCreated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Response Updated
|
||||
|
||||
Example of Response Updated webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"webhookId": "webhookId",
|
||||
"event": "responseUpdated",
|
||||
"data": {
|
||||
"id": "responseId",
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"updatedAt": "2025-07-24T07:47:33.696Z",
|
||||
"surveyId": "surveyId",
|
||||
"displayId": "displayId",
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"finished": false,
|
||||
"endingId": null,
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "Just browsing"
|
||||
},
|
||||
"variables": {},
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 3855.799999952316
|
||||
},
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"os": "macOS",
|
||||
"device": "desktop"
|
||||
},
|
||||
"country": "DE"
|
||||
},
|
||||
"singleUseId": null,
|
||||
"language": "en"
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "Just browsing"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 3855.799999952316
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:33.696Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseUpdated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
### Response Finished
|
||||
|
||||
Example of Response Finished webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"webhookId": "webhookId",
|
||||
"event": "responseFinished",
|
||||
"data": {
|
||||
"id": "responseId",
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"updatedAt": "2025-07-24T07:47:56.116Z",
|
||||
"surveyId": "surveyId",
|
||||
"displayId": "displayId",
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"finished": true,
|
||||
"endingId": "endingId",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "accepted"
|
||||
},
|
||||
"variables": {},
|
||||
"ttc": {
|
||||
"_total": 4947.899999035763,
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 2793.199999988079
|
||||
},
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"os": "macOS",
|
||||
"device": "desktop"
|
||||
},
|
||||
"country": "DE"
|
||||
},
|
||||
"singleUseId": null,
|
||||
"language": "en"
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "accepted"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": "endingId",
|
||||
"finished": true,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"_total": 4947.899999035763,
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 2793.199999988079
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:56.116Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseFinished",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Wordpress"
|
||||
description:
|
||||
"Target specific visitors with a survey on your WordPress page using Formbricks for free. Show survey on specific page or on button click."
|
||||
description: "Target specific visitors with a survey on your WordPress page using Formbricks for free. Show survey on specific page or on button click."
|
||||
---
|
||||
|
||||
To run a targeted survey on your WordPress website, Formbricks is the way to go! 
|
||||
@@ -36,7 +35,7 @@ When you see this screen, you’re there:
|
||||
|
||||
## Step 3: Find and copy the environmentId
|
||||
|
||||
Go to Settings > Setup Checklist where you’ll find your environmentId:
|
||||
Go to `Configuration` > `Website & App Connection` where you’ll find your environmentId:
|
||||
|
||||

|
||||
|
||||
@@ -80,4 +79,4 @@ You did it! Reload the WordPress page and your survey should appear!
|
||||
|
||||
## Doesn't work?
|
||||
|
||||
If you have any questions or need help, feel free to reach out to us on [Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
If you have any questions or need help, feel free to reach out to us on [Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
---
|
||||
title: "Styling Theme"
|
||||
description:
|
||||
"Keep the survey styling consistent over all surveys with a Styling Theme. Customize the colors, fonts, and other styling options to match your brand's aesthetic."
|
||||
description: "Keep the survey styling consistent over all surveys with a Styling Theme. Customize the colors, fonts, and other styling options to match your brand's aesthetic."
|
||||
icon: "palette"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosting Requirements**: Uploading custom background images and brand logos requires file upload
|
||||
storage to be configured. If you're self-hosting Formbricks, make sure to [configure file
|
||||
uploads](/self-hosting/configuration/file-uploads) before using these features.
|
||||
</Note>
|
||||
|
||||
Keep the survey styling consistent over all surveys with a Styling Theme. Customize the colors, fonts, and other styling options to match your brand's aesthetic.
|
||||
|
||||
## Configuration
|
||||
@@ -20,7 +25,6 @@ In the left side bar, you find the `Configuration` page. On this page you find t
|
||||
|
||||

|
||||
|
||||
|
||||
- **Brand Color**: Sets the primary color tone of the survey.
|
||||
- **Text Color**: This is a single color scheme that will be used across to display all the text on your survey. Ensures all text is readable against the background.
|
||||
- **Input Color:** Alters the border color of input fields.
|
||||
@@ -63,17 +67,14 @@ Customize your survey with your brand's logo.
|
||||
|
||||

|
||||
|
||||
|
||||
3. Add a background color: If you’ve uploaded a transparent image and want to add background to it, enable this toggle and select the color of your choice.
|
||||
|
||||

|
||||
|
||||
|
||||
4. Remember to save your changes!
|
||||
|
||||

|
||||
|
||||
|
||||
<Note>The logo settings apply across all Link Surveys pages.</Note>
|
||||
|
||||
## Overwrite Styling Theme
|
||||
|
||||
@@ -9,10 +9,10 @@ Add new members to your Formbricks organization to collaborate on surveys and ma
|
||||
## Prerequisites
|
||||
|
||||
To invite members, you need:
|
||||
|
||||
- **Owner** or **Manager** role in the organization
|
||||
- Valid email addresses for the people you want to invite
|
||||
|
||||
|
||||
## Individual invitations
|
||||
|
||||
Use this method when inviting a few people or when you need to carefully control each invitation.
|
||||
@@ -22,18 +22,21 @@ Use this method when inviting a few people or when you need to carefully control
|
||||
<Steps>
|
||||
<Step title="Navigate to Organization Settings > Access Control">
|
||||
Go to the organization settings page and click on the "Access Control" tab.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Start the invitation process">
|
||||
Click on the `Add member` button:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Fill in member details">
|
||||
In the modal, add the Name, Email and Role of the organization member you want to invite:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Send the invitation">
|
||||
@@ -62,12 +65,14 @@ Use bulk invitations when you need to invite many people at once, such as when o
|
||||
Click on the `Add member` button:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Switch to bulk invite">
|
||||
In the modal, switch to `Bulk Invite`. You can download an example .CSV file to fill in the Name, Email and Role of the organization members you want to invite:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Prepare your CSV file">
|
||||
@@ -99,6 +104,7 @@ Use bulk invitations when you need to invite many people at once, such as when o
|
||||
### Invitation status
|
||||
|
||||
Monitor the status of your invitations:
|
||||
|
||||
- **Pending**: Invitation sent but not yet accepted
|
||||
- **Accepted**: User has joined the organization
|
||||
- **Expired**: Invitation has expired and needs to be resent
|
||||
- **Expired**: Invitation has expired and needs to be resent
|
||||
|
||||
@@ -4,6 +4,12 @@ description: "Enhance your questions by adding images or videos. This makes inst
|
||||
icon: "image"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosting Requirements**: Adding images to questions requires file upload storage to be configured. If
|
||||
you're self-hosting Formbricks, make sure to [configure file
|
||||
uploads](/self-hosting/configuration/file-uploads) before using this feature.
|
||||
</Note>
|
||||
|
||||
## How to Add Images
|
||||
|
||||
Click the icon on the right side of the question to add an image or video:
|
||||
@@ -25,6 +31,6 @@ Toggle to add a video via link:
|
||||
We support YouTube, Vimeo, and Loom URLs.
|
||||
|
||||
<Note>
|
||||
**YouTube Privacy Mode**: This option reduces tracking by converting YouTube
|
||||
URLs to no-cookie URLs. It only works with YouTube.
|
||||
**YouTube Privacy Mode**: This option reduces tracking by converting YouTube URLs to no-cookie URLs. It only
|
||||
works with YouTube.
|
||||
</Note>
|
||||
|
||||
@@ -8,10 +8,6 @@ icon: "chart-pie"
|
||||
|
||||
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
|
||||
|
||||
<Note type="warning">
|
||||
Quota Management is currently in beta and only available to select customers.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
title: "Schedule Start & End Dates"
|
||||
description: "Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys."
|
||||
icon: "calendar-days"
|
||||
---
|
||||
|
||||
Configure your surveys to open and close based on specific criteria. Here’s how to set up these conditions:
|
||||
|
||||
## **Schedule a Survey Release**
|
||||
|
||||
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Release Survey on Date”.
|
||||
|
||||

|
||||
|
||||
- **Details**: Choose the date and time when the survey should become available to respondents. All times follow UTC timezone.
|
||||
|
||||
- **Use Case**: This is useful for launching surveys in alignment with events, product releases, or specific marketing campaigns.
|
||||
|
||||
## **Automatically Closing a Survey**
|
||||
|
||||
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on date”.
|
||||
|
||||

|
||||
|
||||
- **Details**: Define a specific date and time for the survey to close. This also follows UTC timezone. 
|
||||
|
||||
- **Use Case**: Essential for surveys linked to time-bound events or studies where data collection needs to end
|
||||
at a specific point.
|
||||
|
||||
### **Summary**
|
||||
|
||||
Setting up Start & End Dates in Formbricks allows you to control the availability and duration of your surveys with precision. Whether you are conducting academic research, market analysis, or gathering event feedback, these settings help ensure that your data collection aligns perfectly with your objectives.
|
||||
|
||||
---
|
||||
143
docs/xm-and-surveys/surveys/general-features/tags.mdx
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: "Tags"
|
||||
description: "Organize and categorize survey responses to easily filter, analyze, and manage your data."
|
||||
icon: "tag"
|
||||
---
|
||||
|
||||
## What are Tags?
|
||||
|
||||
Tags are labels that you can apply to individual survey responses. They allow you to:
|
||||
|
||||
- Categorize responses by topic, sentiment, or any custom criteria
|
||||
- Filter responses to find specific subsets of data
|
||||
- Track and organize feedback across multiple surveys
|
||||
- Simplify analysis and reporting workflows
|
||||
|
||||
Tags are environment-specific, meaning each environment maintains its own set of tags.
|
||||
|
||||
## Add tags to responses
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to responses">
|
||||
Go to the **Responses** tab of your survey.
|
||||
</Step>
|
||||
|
||||
<Step title="Open a response">
|
||||
Click on any response card to view the full response details.
|
||||
</Step>
|
||||
|
||||
<Step title="Add a tag">
|
||||
At the bottom of the response card, click the **Add Tag** button.
|
||||
</Step>
|
||||
|
||||
<Step title="Select or create a tag">
|
||||
- Select an existing tag from the dropdown list, or
|
||||
- Type a new tag name and click **+ Add [tag name]** to create a new tag
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The tag will be immediately applied to the response. You can add multiple tags to a single response.
|
||||
|
||||
## Remove tags from responses
|
||||
|
||||
To remove a tag from a response:
|
||||
|
||||
1. Open the response card
|
||||
2. Click the **X** icon on the tag you want to remove
|
||||
|
||||
The tag will be removed from the response immediately.
|
||||
|
||||
## Manage tags
|
||||
|
||||
Access the tag management page to view and organize all tags in your environment.
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to Configuration">
|
||||
Click on **Project Configuration** > **Tags**.
|
||||
</Step>
|
||||
|
||||
<Step title="View all tags">
|
||||
You'll see a list of all tags in your environment with their usage count showing how many responses have each tag applied.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Edit tag names
|
||||
|
||||
1. In the tag management page, click on the tag name field
|
||||
2. Edit the name directly
|
||||
3. Click outside the field or press Enter to save
|
||||
|
||||
<Note>
|
||||
Tag names must be unique within an environment. If you try to use an existing tag name, you'll receive an error.
|
||||
</Note>
|
||||
|
||||
### Merge tags
|
||||
|
||||
Merging tags is useful when you have duplicate or similar tags that you want to consolidate.
|
||||
|
||||
1. In the tag management page, find the tag you want to merge
|
||||
2. Click the **Merge into** dropdown
|
||||
3. Select the destination tag
|
||||
4. Confirm the merge
|
||||
|
||||
All responses tagged with the original tag will be updated to use the destination tag, and the original tag will be deleted.
|
||||
|
||||
### Delete tags
|
||||
|
||||
1. In the tag management page, find the tag you want to delete
|
||||
2. Click the **Delete** button
|
||||
3. Confirm the deletion
|
||||
|
||||
<Warning>
|
||||
Deleting a tag will remove it from all responses. This action cannot be undone.
|
||||
</Warning>
|
||||
|
||||
## Filter responses by tags
|
||||
|
||||
Use tags to filter and find specific responses in your survey analysis.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open filters">
|
||||
In the **Responses** tab, click the **Filter** button.
|
||||
</Step>
|
||||
|
||||
<Step title="Select tag filter">
|
||||
Scroll to the **Tags** section and select a tag from the dropdown.
|
||||
</Step>
|
||||
|
||||
<Step title="Choose filter type">
|
||||
Choose whether to filter by:
|
||||
- **Applied**: Show only responses that have this tag
|
||||
- **Not Applied**: Show only responses that don't have this tag
|
||||
</Step>
|
||||
|
||||
<Step title="View filtered results">
|
||||
The response list will update to show only responses matching your tag filter.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
You can combine tag filters with other filters (questions, attributes, metadata) to create complex filter criteria.
|
||||
|
||||
## Use cases
|
||||
|
||||
Here are some common ways to use tags:
|
||||
|
||||
- **Sentiment tracking**: Tag responses as "positive", "negative", or "neutral"
|
||||
- **Follow-up needed**: Mark responses that require action with a "follow-up" tag
|
||||
- **Feature requests**: Categorize feedback by product area or feature
|
||||
- **Customer segments**: Tag responses by customer type, industry, or plan level
|
||||
- **Priority levels**: Mark critical issues with "urgent" or "high-priority" tags
|
||||
- **Review status**: Track which responses have been reviewed with "reviewed" or "pending" tags
|
||||
|
||||
## Permissions
|
||||
|
||||
Tag management requires appropriate permissions:
|
||||
|
||||
- **View tags**: All users with access to the environment can view tags on responses
|
||||
- **Add/remove tags on responses**: Users with read-write access can apply and remove tags
|
||||
- **Manage tags** (create, edit, merge, delete): Users with project team read-write permission or organization owner/manager roles
|
||||
|
||||
---
|
||||
|
||||
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
|
||||
@@ -4,6 +4,12 @@ description: "Customize link titles, descriptions, and preview images to make yo
|
||||
icon: "gear"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosting Requirements**: Adding a preview image requires file upload storage to be configured. If
|
||||
you're self-hosting Formbricks, make sure to [configure file
|
||||
uploads](/self-hosting/configuration/file-uploads) before using this feature.
|
||||
</Note>
|
||||
|
||||
## What are Link Settings?
|
||||
|
||||
Link Settings allow you to configure the metadata (Open Graph tags) for your survey links, controlling how they appear when shared:
|
||||
@@ -14,7 +20,6 @@ Link Settings allow you to configure the metadata (Open Graph tags) for your sur
|
||||
|
||||

|
||||
|
||||
|
||||
## Configuring Link Metadata
|
||||
|
||||
<Steps>
|
||||
@@ -22,21 +27,22 @@ Link Settings allow you to configure the metadata (Open Graph tags) for your sur
|
||||
Navigate to your survey's Summary page and click the **Share survey** button in the top toolbar.
|
||||
</Step>
|
||||
|
||||
<Step title="Open Link Settings tab">
|
||||
In the Share Modal, click on the **Link Settings** tab to access the customization options.
|
||||
</Step>
|
||||
<Step title="Open Link Settings tab">
|
||||
In the Share Modal, click on the **Link Settings** tab to access the customization options.
|
||||
</Step>
|
||||
|
||||
<Step title="Customize your link title">
|
||||
Enter a title for your survey link. This will appear as the main headline when your link is shared.
|
||||
</Step>
|
||||
<Step title="Customize your link title">
|
||||
Enter a title for your survey link. This will appear as the main headline when your link is shared.
|
||||
</Step>
|
||||
|
||||
<Step title="Add a link description">
|
||||
Write a brief description for your survey. This will appear as the description of your Survey Link.
|
||||
</Step>
|
||||
<Step title="Add a link description">
|
||||
Write a brief description for your survey. This will appear as the description of your Survey Link.
|
||||
</Step>
|
||||
|
||||
<Step title="Upload a preview image">
|
||||
Add a custom image that will display when your link is shared. This makes your survey more visually appealing and can increase engagement.
|
||||
</Step>
|
||||
<Step title="Upload a preview image">
|
||||
Add a custom image that will display when your link is shared. This makes your survey more visually
|
||||
appealing and can increase engagement.
|
||||
</Step>
|
||||
|
||||
<Step title="Save your settings">
|
||||
Click **Save** to apply your link settings. These changes will take effect immediately for all future link shares.
|
||||
|
||||
@@ -4,6 +4,12 @@ description: "The File Upload question type allows respondents to upload files r
|
||||
icon: "upload"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosting Requirements**: This question type requires file upload storage to be configured. If you're
|
||||
self-hosting Formbricks, make sure to [configure file uploads](/self-hosting/configuration/file-uploads)
|
||||
before using this feature.
|
||||
</Note>
|
||||
|
||||
<iframe
|
||||
title="Survey Embed"
|
||||
src="https://app.formbricks.com/s/oo4e6vva48w0trn01ht8krwo"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
---
|
||||
title: "Picture Selection"
|
||||
description:
|
||||
"Picture selection questions allow respondents to select one or more images from a list"
|
||||
description: "Picture selection questions allow respondents to select one or more images from a list"
|
||||
icon: "image"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosting Requirements**: This question type requires file upload storage to be configured for image
|
||||
uploads. If you're self-hosting Formbricks, make sure to [configure file
|
||||
uploads](/self-hosting/configuration/file-uploads) before using this feature.
|
||||
</Note>
|
||||
|
||||
Picture selection questions allow respondents to select one or more images from a list. Displays a title and a list of images for the respondent to choose from.
|
||||
|
||||
<iframe
|
||||
@@ -24,6 +29,7 @@ Picture selection questions allow respondents to select one or more images from
|
||||
## Elements
|
||||
|
||||

|
||||
|
||||
### Title
|
||||
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
@@ -60,7 +60,7 @@ Formbricks offers an intuitive No-Code interface that allows you to configure ac
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Visit the Actions tab via the main navigation">
|
||||
<Step title="Visit the Configuration tab and click on the `Website & App Connection`">
|
||||

|
||||
</Step>
|
||||
|
||||
|
||||
139
packages/cache/.cursor/rules/cache-package.md
vendored
@@ -4,9 +4,10 @@
|
||||
|
||||
### Redis-Only Architecture
|
||||
- **Mandatory Redis**: All deployments MUST use Redis via `REDIS_URL` environment variable
|
||||
- **Singleton Client**: Use `getCacheService()` - returns singleton instance per process
|
||||
- **Singleton Client**: Use `getCacheService()` - returns singleton instance per process using `globalThis`
|
||||
- **Result Types**: Core operations return `Result<T, CacheError>` for explicit error handling
|
||||
- **Never-Failing Wrappers**: `withCache()` always returns function result, handling cache errors internally
|
||||
- **Cross-Platform**: Uses `globalThis` for Edge Runtime, Lambda, and HMR compatibility
|
||||
|
||||
### Type Safety & Validation
|
||||
- **Branded Cache Keys**: Use `CacheKey` type to prevent raw string usage
|
||||
@@ -17,35 +18,41 @@
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts # Main exports (getCacheService, createCacheKey, types)
|
||||
├── client.ts # Singleton cache service client with Redis connection
|
||||
├── service.ts # Core CacheService class with Result types + withCache helpers
|
||||
├── cache-keys.ts # Cache key generators with branded types
|
||||
├── index.ts # Main exports
|
||||
├── client.ts # globalThis singleton with getCacheService()
|
||||
├── service.ts # CacheService class with Result types + withCache
|
||||
├── cache-keys.ts # Cache key generators with branded types
|
||||
├── cache-integration.test.ts # E2E tests exercising Redis operations
|
||||
├── utils/
|
||||
│ ├── validation.ts # Zod validation utilities
|
||||
│ └── key.ts # makeCacheKey utility (not exported)
|
||||
└── *.test.ts # Unit tests
|
||||
│ ├── validation.ts # Zod validation utilities
|
||||
│ └── key.ts # makeCacheKey utility (not exported)
|
||||
└── *.test.ts # Unit tests
|
||||
types/
|
||||
├── keys.ts # Branded CacheKey type & CustomCacheNamespace
|
||||
├── client.ts # RedisClient type definition
|
||||
├── service.ts # Zod schemas and validateInputs function
|
||||
├── error.ts # Result type system and error definitions
|
||||
└── *.test.ts # Type tests
|
||||
├── keys.ts # Branded CacheKey type & CustomCacheNamespace
|
||||
├── client.ts # RedisClient type definition
|
||||
├── service.ts # Zod schemas and validateInputs function
|
||||
├── error.ts # Result type system and error definitions
|
||||
└── *.test.ts # Type tests
|
||||
```
|
||||
|
||||
## Required Patterns
|
||||
|
||||
### Singleton Client Pattern
|
||||
### globalThis Singleton Pattern
|
||||
```typescript
|
||||
// ✅ GOOD - Use singleton client
|
||||
// ✅ GOOD - Use globalThis singleton client
|
||||
import { getCacheService } from "@formbricks/cache";
|
||||
const result = await getCacheService();
|
||||
if (!result.ok) {
|
||||
// Handle initialization error
|
||||
// Handle initialization error - Redis connection failed
|
||||
logger.error({ error: result.error }, "Cache service unavailable");
|
||||
throw new Error(`Cache failed: ${result.error.code}`);
|
||||
}
|
||||
const cacheService = result.data;
|
||||
|
||||
// ✅ GOOD - Production validation (index.ts)
|
||||
import { validateRedisConfig } from "@formbricks/cache";
|
||||
validateRedisConfig(); // Throws if REDIS_URL missing in production
|
||||
|
||||
// ❌ BAD - CacheService class not exported for direct instantiation
|
||||
import { CacheService } from "@formbricks/cache"; // Won't work!
|
||||
```
|
||||
@@ -71,6 +78,10 @@ const environmentData = await cacheService.withCache(
|
||||
createCacheKey.environment.state(environmentId),
|
||||
60000
|
||||
); // Returns T directly, handles cache errors internally
|
||||
|
||||
// ✅ GOOD - Structured logging with context first
|
||||
logger.error({ error, key, operation: "cache_get" }, "Cache operation failed");
|
||||
logger.warn({ error }, "Cache unavailable; executing function directly");
|
||||
```
|
||||
|
||||
### Core Validation & Error Types
|
||||
@@ -91,7 +102,7 @@ export const ZCacheKey = z.string().min(1).refine(k => k.trim().length > 0);
|
||||
// TTL validation: min 1000ms for Redis seconds conversion
|
||||
export const ZTtlMs = z.number().int().min(1000).finite();
|
||||
|
||||
// Generic validation function
|
||||
// Generic validation function (returns array of validated values)
|
||||
export function validateInputs(...pairs: [unknown, ZodType][]): Result<unknown[], CacheError>;
|
||||
```
|
||||
|
||||
@@ -137,10 +148,21 @@ await cacheService.exists(key): Promise<Result<boolean, CacheError>>
|
||||
// withCache never fails - returns T directly, handles cache errors internally
|
||||
await cacheService.withCache<T>(fn, key, ttlMs): Promise<T>
|
||||
|
||||
// Redis availability check with ping test (standardized across codebase)
|
||||
await cacheService.isRedisAvailable(): Promise<boolean>
|
||||
|
||||
// Direct Redis access for advanced operations (rate limiting, etc.)
|
||||
cacheService.getRedisClient(): RedisClient | null
|
||||
```
|
||||
|
||||
### Redis Availability Method
|
||||
Standardized Redis connectivity check across the codebase.
|
||||
|
||||
**Method Implementation:**
|
||||
- `isRedisAvailable()`: Checks client state (`isReady && isOpen`) + Redis ping test
|
||||
- Returns `Promise<boolean>` - true if Redis is available and responsive
|
||||
- Used for health monitoring, status checks, and external validation
|
||||
|
||||
### Service Implementation - Cognitive Complexity Reduction
|
||||
The `withCache` method is split into helper methods to reduce cognitive complexity:
|
||||
|
||||
@@ -223,13 +245,42 @@ return await fn(); // Always return function result
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Key Test Areas
|
||||
### Unit Tests (*.test.ts)
|
||||
- **Result error cases**: Validation, Redis, corruption errors
|
||||
- **Null vs undefined**: Caching behavior differences
|
||||
- **withCache fallbacks**: Cache failures gracefully handled
|
||||
- **Edge cases**: Empty arrays, invalid TTLs, malformed keys
|
||||
- **Mock dependencies**: Redis client, logger with all levels
|
||||
|
||||
### Integration Tests (cache-integration.test.ts)
|
||||
- **End-to-End Redis Operations**: Tests against live Redis instance
|
||||
- **Auto-Skip Logic**: Automatically skips when Redis unavailable (`REDIS_URL` not set)
|
||||
- **Comprehensive Coverage**: All cache operations through real code paths
|
||||
- **CI Integration**: Runs in E2E workflow with Redis/Valkey service
|
||||
- **Logger Integration**: Uses `@formbricks/logger` with structured logging
|
||||
|
||||
```typescript
|
||||
// ✅ Integration test pattern
|
||||
describe("Cache Integration Tests", () => {
|
||||
beforeAll(async () => {
|
||||
isRedisAvailable = await checkRedisAvailability();
|
||||
if (!isRedisAvailable) {
|
||||
logger.info("🟡 Tests skipped - Redis not available");
|
||||
return;
|
||||
}
|
||||
logger.info("🟢 Tests will run - Redis available");
|
||||
});
|
||||
|
||||
test("withCache miss/hit pattern", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
// Test cache miss -> hit behavior with real Redis
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Web App Integration Pattern
|
||||
|
||||
### Cache Facade (apps/web/lib/cache/index.ts)
|
||||
@@ -255,37 +306,41 @@ const redis = await cache.getRedisClient();
|
||||
```
|
||||
|
||||
### Proxy Implementation
|
||||
- **No Singleton Management**: Calls `getCacheService()` for each operation
|
||||
- **Proxy Pattern**: Transparent method forwarding to underlying cache service
|
||||
- **Graceful Degradation**: withCache falls back to direct execution on cache failure
|
||||
- **Lazy Initialization**: Calls `getCacheService()` for each operation via Proxy
|
||||
- **Graceful Degradation**: `withCache` falls back to direct execution on cache failure
|
||||
- **Server-Only**: Uses "server-only" import to prevent client-side usage
|
||||
- **Production Validation**: Validates `REDIS_URL` at module initialization
|
||||
|
||||
## Import/Export Standards
|
||||
## Architecture Updates
|
||||
|
||||
### globalThis Singleton (client.ts)
|
||||
```typescript
|
||||
// ✅ GOOD - Package root exports (index.ts)
|
||||
export { getCacheService } from "./client";
|
||||
export type { CacheService } from "./service";
|
||||
export { createCacheKey } from "./cache-keys";
|
||||
export type { CacheKey } from "../types/keys";
|
||||
export type { Result, CacheError } from "../types/error";
|
||||
export { CacheErrorClass, ErrorCode } from "../types/error";
|
||||
// Cross-platform singleton using globalThis (not global)
|
||||
const globalForCache = globalThis as unknown as {
|
||||
formbricksCache: CacheService | undefined;
|
||||
formbricksCacheInitializing: Promise<Result<CacheService, CacheError>> | undefined;
|
||||
};
|
||||
|
||||
// ❌ BAD - Don't export these (encapsulation)
|
||||
// export { createRedisClientFromEnv } from "./client"; // Internal only
|
||||
// export type { RedisClient } from "../types/client"; // Internal only
|
||||
// export { CacheService } from "./service"; // Only type exported
|
||||
// Prevents multiple Redis connections in HMR/serverless/Edge Runtime
|
||||
export async function getCacheService(): Promise<Result<CacheService, CacheError>>;
|
||||
```
|
||||
|
||||
### Fast-Fail Connection Strategy
|
||||
- **No Reconnection in Factory**: Redis client uses fast-fail connection
|
||||
- **Background Reconnection**: Handled by Redis client's built-in retry logic
|
||||
- **Early Checks**: `isReady` check at method start to avoid 1-second timeouts
|
||||
- **Graceful Degradation**: `withCache` executes function when cache unavailable
|
||||
|
||||
## Key Rules Summary
|
||||
|
||||
1. **Singleton Client**: Use `getCacheService()` - returns singleton per process
|
||||
2. **Result Types**: Core ops return `Result<T, CacheError>` - no throwing
|
||||
1. **globalThis Singleton**: Use `getCacheService()` - cross-platform singleton
|
||||
2. **Result Types**: Core ops return `Result<T, CacheError>` - no throwing
|
||||
3. **Never-Failing withCache**: Returns `T` directly, handles cache errors internally
|
||||
4. **Validation**: Use `validateInputs()` function for all input validation
|
||||
5. **Error Interface**: Single `CacheError` interface with just `code` field
|
||||
6. **Logging**: Rich logging at source, clean Results for consumers
|
||||
7. **TTL Minimum**: 1000ms minimum for Redis conversion (ms → seconds)
|
||||
8. **Type Safety**: Branded `CacheKey` type prevents raw string usage
|
||||
9. **Encapsulation**: RedisClient and createRedisClientFromEnv are internal only
|
||||
10. **Cognitive Complexity**: Split complex methods into focused helper methods
|
||||
4. **Standardized Redis Check**: Use `isRedisAvailable()` method with ping test
|
||||
5. **Structured Logging**: Context object first, then message string
|
||||
6. **Fast-Fail Strategy**: Early Redis availability checks, no blocking timeouts
|
||||
7. **Integration Testing**: E2E tests with auto-skip logic for development
|
||||
8. **Production Validation**: Mandatory `REDIS_URL` with startup validation
|
||||
9. **Cross-Platform**: Uses `globalThis` for Edge Runtime/Lambda compatibility
|
||||
10. **CI Integration**: Cache tests run in E2E workflow with Redis service
|
||||
11. **Cognitive Complexity**: Split complex methods into focused helper methods
|
||||
550
packages/cache/src/cache-integration.test.ts
vendored
Normal file
@@ -0,0 +1,550 @@
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-non-null-assertion, @typescript-eslint/require-await -- Test file needs template expressions for test output */
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { createCacheKey } from "./cache-keys";
|
||||
import { getCacheService } from "./client";
|
||||
import type { CacheService } from "./service";
|
||||
|
||||
// Check if Redis is available
|
||||
let isRedisAvailable = false;
|
||||
let cacheService: CacheService | null = null;
|
||||
|
||||
// Helper to reduce nesting depth
|
||||
const delay = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
// Test Redis availability
|
||||
async function checkRedisAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const cacheServiceResult = await getCacheService();
|
||||
if (!cacheServiceResult.ok) {
|
||||
logger.info("Cache service unavailable - Redis not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
|
||||
if (isAvailable) {
|
||||
logger.info("Redis availability check successful - Redis is available");
|
||||
cacheService = cacheServiceResult.data;
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("Redis availability check failed - Redis not available");
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error checking Redis availability");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache Integration Tests - End-to-End Redis Operations
|
||||
*
|
||||
* This test suite verifies that cache operations work correctly through the actual
|
||||
* CacheService API against a live Redis instance. These tests exercise real code paths
|
||||
* that the application uses in production.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Redis server must be running and accessible
|
||||
* - REDIS_URL environment variable must be set to a valid Redis connection string
|
||||
* - Tests will be automatically skipped if REDIS_URL is empty or Redis client is not available
|
||||
*
|
||||
* Running the tests:
|
||||
* Local development: cd packages/cache && npx vitest run src/cache-integration.test.ts
|
||||
* CI Environment: Tests run automatically in E2E workflow with Redis/Valkey service
|
||||
*
|
||||
* Test Scenarios:
|
||||
*
|
||||
* 1. Basic Cache Operations
|
||||
* - Purpose: Verify basic get/set/del operations work correctly
|
||||
* - Method: Set a value, get it, delete it, verify deletion
|
||||
* - Expected: All operations succeed with correct return values
|
||||
* - Failure Indicates: Basic Redis connectivity or operation issues
|
||||
*
|
||||
* 2. withCache Miss/Hit Pattern
|
||||
* - Purpose: Verify cache-aside pattern implementation
|
||||
* - Method: Call withCache twice with expensive function
|
||||
* - Expected: First call executes function (miss), second call returns cached value (hit)
|
||||
* - Failure Indicates: Cache miss/hit logic not working correctly
|
||||
*
|
||||
* 3. Cache Invalidation
|
||||
* - Purpose: Verify that del() clears cache and forces recomputation
|
||||
* - Method: Cache a value, invalidate it, call withCache again
|
||||
* - Expected: Function executes again after invalidation
|
||||
* - Failure Indicates: Cache invalidation not working
|
||||
*
|
||||
* 4. TTL Expiry Behavior
|
||||
* - Purpose: Verify automatic cache expiration
|
||||
* - Method: Set value with short TTL, wait for expiration, verify gone
|
||||
* - Expected: Value expires automatically and subsequent calls recompute
|
||||
* - Failure Indicates: TTL not working correctly
|
||||
*
|
||||
* 5. Concurrent Cache Operations
|
||||
* - Purpose: Test thread safety of cache operations
|
||||
* - Method: Multiple concurrent get/set operations on same key
|
||||
* - Expected: No corruption, consistent behavior
|
||||
* - Failure Indicates: Race conditions in cache operations
|
||||
*
|
||||
* 6. Different Data Types
|
||||
* - Purpose: Verify serialization works for various data types
|
||||
* - Method: Store objects, arrays, primitives, complex nested data
|
||||
* - Expected: Data round-trips correctly without corruption
|
||||
* - Failure Indicates: Serialization/deserialization issues
|
||||
*
|
||||
* 7. Error Handling
|
||||
* - Purpose: Verify graceful error handling when Redis is unavailable
|
||||
* - Method: Test operations when Redis connection is lost
|
||||
* - Expected: Graceful degradation, proper error types returned
|
||||
* - Failure Indicates: Poor error handling
|
||||
*
|
||||
* Success Indicators:
|
||||
* ✅ All cache operations complete successfully
|
||||
* ✅ Cache hits/misses behave as expected
|
||||
* ✅ TTL expiration works correctly
|
||||
* ✅ Data integrity maintained across operations
|
||||
* ✅ Proper error handling when Redis unavailable
|
||||
*
|
||||
* Failure Indicators:
|
||||
* ❌ Cache operations fail unexpectedly
|
||||
* ❌ Cache hits don't work (always executing expensive operations)
|
||||
* ❌ TTL not expiring keys
|
||||
* ❌ Data corruption or serialization issues
|
||||
* ❌ Poor error handling
|
||||
*/
|
||||
|
||||
describe("Cache Integration Tests - End-to-End Redis Operations", () => {
|
||||
beforeAll(async () => {
|
||||
// Check Redis availability first
|
||||
isRedisAvailable = await checkRedisAvailability();
|
||||
|
||||
if (!isRedisAvailable) {
|
||||
logger.info("🟡 Cache Integration Tests: Redis not available - tests will be skipped");
|
||||
logger.info(" To run these tests locally, ensure Redis is running and REDIS_URL is set");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("🟢 Cache Integration Tests: Redis available - tests will run");
|
||||
|
||||
// Clear any existing test keys
|
||||
if (cacheService) {
|
||||
const redis = cacheService.getRedisClient();
|
||||
if (redis) {
|
||||
const testKeys = await redis.keys("fb:cache:test:*");
|
||||
if (testKeys.length > 0) {
|
||||
await redis.del(testKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test keys
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping cleanup: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const redis = cacheService.getRedisClient();
|
||||
if (redis) {
|
||||
const testKeys = await redis.keys("fb:cache:test:*");
|
||||
if (testKeys.length > 0) {
|
||||
await redis.del(testKeys);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Basic cache operations: set, get, exists, del", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = createCacheKey.environment.state("basic-ops-test");
|
||||
const testValue = { message: "Hello Cache!", timestamp: Date.now(), count: 42 };
|
||||
|
||||
// Test set operation
|
||||
const setResult = await cacheService.set(key, testValue, 60000); // 60 seconds TTL
|
||||
expect(setResult.ok).toBe(true);
|
||||
logger.info("✅ Set operation successful");
|
||||
|
||||
// Test exists operation
|
||||
const existsResult = await cacheService.exists(key);
|
||||
expect(existsResult.ok).toBe(true);
|
||||
if (existsResult.ok) {
|
||||
expect(existsResult.data).toBe(true);
|
||||
}
|
||||
logger.info("✅ Exists operation confirmed key exists");
|
||||
|
||||
// Test get operation
|
||||
const getResult = await cacheService.get<typeof testValue>(key);
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (getResult.ok) {
|
||||
expect(getResult.data).toEqual(testValue);
|
||||
}
|
||||
logger.info("✅ Get operation returned correct value");
|
||||
|
||||
// Test del operation
|
||||
const delResult = await cacheService.del([key]);
|
||||
expect(delResult.ok).toBe(true);
|
||||
logger.info("✅ Del operation successful");
|
||||
|
||||
// Verify key no longer exists
|
||||
const existsAfterDelResult = await cacheService.exists(key);
|
||||
expect(existsAfterDelResult.ok).toBe(true);
|
||||
if (existsAfterDelResult.ok) {
|
||||
expect(existsAfterDelResult.data).toBe(false);
|
||||
}
|
||||
logger.info("✅ Key confirmed deleted");
|
||||
|
||||
// Verify get returns null after deletion
|
||||
const getAfterDelResult = await cacheService.get(key);
|
||||
expect(getAfterDelResult.ok).toBe(true);
|
||||
if (getAfterDelResult.ok) {
|
||||
expect(getAfterDelResult.data).toBe(null);
|
||||
}
|
||||
logger.info("✅ Get after deletion returns null");
|
||||
}, 10000);
|
||||
|
||||
test("withCache miss/hit pattern: first call miss, second call hit", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = createCacheKey.environment.state("miss-hit-test");
|
||||
let executionCount = 0;
|
||||
|
||||
// Expensive function that we want to cache
|
||||
const expensiveFunction = async (): Promise<{ result: string; timestamp: number; execution: number }> => {
|
||||
executionCount++;
|
||||
// Simulate expensive operation
|
||||
await delay(10);
|
||||
return {
|
||||
result: "expensive computation result",
|
||||
timestamp: Date.now(),
|
||||
execution: executionCount,
|
||||
};
|
||||
};
|
||||
|
||||
// Clear any existing cache for this key
|
||||
await cacheService.del([key]);
|
||||
|
||||
logger.info("First call (cache miss expected)...");
|
||||
const firstCall = await cacheService.withCache(expensiveFunction, key, 60000);
|
||||
expect(firstCall.execution).toBe(1);
|
||||
expect(executionCount).toBe(1);
|
||||
logger.info(`✅ First call executed function: execution=${firstCall.execution}`);
|
||||
|
||||
logger.info("Second call (cache hit expected)...");
|
||||
const secondCall = await cacheService.withCache(expensiveFunction, key, 60000);
|
||||
expect(secondCall.execution).toBe(1); // Should be the cached value from first call
|
||||
expect(executionCount).toBe(1); // Function should not have been called again
|
||||
expect(secondCall.result).toBe(firstCall.result);
|
||||
logger.info(`✅ Second call returned cached value: execution=${secondCall.execution}`);
|
||||
|
||||
// Verify the values are identical (cache hit)
|
||||
expect(secondCall).toEqual(firstCall);
|
||||
logger.info("✅ Cache hit confirmed - identical values returned");
|
||||
}, 15000);
|
||||
|
||||
test("Cache invalidation: del() clears cache and forces recomputation", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = createCacheKey.environment.state("invalidation-test");
|
||||
let executionCount = 0;
|
||||
|
||||
const expensiveFunction = async (): Promise<{ value: string; execution: number }> => {
|
||||
executionCount++;
|
||||
return {
|
||||
value: `computation-${executionCount}`,
|
||||
execution: executionCount,
|
||||
};
|
||||
};
|
||||
|
||||
// Clear any existing cache
|
||||
await cacheService.del([key]);
|
||||
|
||||
logger.info("First call - populate cache...");
|
||||
const firstResult = await cacheService.withCache(expensiveFunction, key, 60000);
|
||||
expect(firstResult.execution).toBe(1);
|
||||
expect(executionCount).toBe(1);
|
||||
logger.info(`✅ Cache populated: ${firstResult.value}`);
|
||||
|
||||
logger.info("Second call - should hit cache...");
|
||||
const secondResult = await cacheService.withCache(expensiveFunction, key, 60000);
|
||||
expect(secondResult.execution).toBe(1); // Same as first call (cached)
|
||||
expect(executionCount).toBe(1); // Function not executed again
|
||||
expect(secondResult).toEqual(firstResult);
|
||||
logger.info(`✅ Cache hit confirmed: ${secondResult.value}`);
|
||||
|
||||
logger.info("Invalidating cache...");
|
||||
const delResult = await cacheService.del([key]);
|
||||
expect(delResult.ok).toBe(true);
|
||||
logger.info("✅ Cache invalidated");
|
||||
|
||||
logger.info("Third call after invalidation - should miss cache and recompute...");
|
||||
const thirdResult = await cacheService.withCache(expensiveFunction, key, 60000);
|
||||
expect(thirdResult.execution).toBe(2); // New execution
|
||||
expect(executionCount).toBe(2); // Function executed again
|
||||
expect(thirdResult.value).toBe("computation-2");
|
||||
expect(thirdResult).not.toEqual(firstResult);
|
||||
logger.info(`✅ Cache miss after invalidation confirmed: ${thirdResult.value}`);
|
||||
|
||||
logger.info("Fourth call - should hit cache again...");
|
||||
const fourthResult = await cacheService.withCache(expensiveFunction, key, 60000);
|
||||
expect(fourthResult.execution).toBe(2); // Same as third call (cached)
|
||||
expect(executionCount).toBe(2); // Function not executed again
|
||||
expect(fourthResult).toEqual(thirdResult);
|
||||
logger.info(`✅ Cache repopulated and hit: ${fourthResult.value}`);
|
||||
}, 15000);
|
||||
|
||||
test("TTL expiry behavior: cache expires automatically", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = createCacheKey.environment.state("ttl-expiry-test");
|
||||
let executionCount = 0;
|
||||
|
||||
const expensiveFunction = async (): Promise<{ value: string; execution: number }> => {
|
||||
executionCount++;
|
||||
return {
|
||||
value: `ttl-computation-${executionCount}`,
|
||||
execution: executionCount,
|
||||
};
|
||||
};
|
||||
|
||||
// Clear any existing cache
|
||||
await cacheService.del([key]);
|
||||
|
||||
logger.info("First call with short TTL (2 seconds)...");
|
||||
const firstResult = await cacheService.withCache(expensiveFunction, key, 2000); // 2 second TTL
|
||||
expect(firstResult.execution).toBe(1);
|
||||
expect(executionCount).toBe(1);
|
||||
logger.info(`✅ Cache populated with TTL: ${firstResult.value}`);
|
||||
|
||||
logger.info("Second call within TTL - should hit cache...");
|
||||
const secondResult = await cacheService.withCache(expensiveFunction, key, 2000);
|
||||
expect(secondResult.execution).toBe(1); // Same as first call (cached)
|
||||
expect(executionCount).toBe(1); // Function not executed again
|
||||
expect(secondResult).toEqual(firstResult);
|
||||
logger.info(`✅ Cache hit within TTL: ${secondResult.value}`);
|
||||
|
||||
logger.info("Waiting for TTL expiry (3 seconds)...");
|
||||
await delay(3000);
|
||||
|
||||
logger.info("Third call after TTL expiry - should miss cache and recompute...");
|
||||
const thirdResult = await cacheService.withCache(expensiveFunction, key, 2000);
|
||||
expect(thirdResult.execution).toBe(2); // New execution
|
||||
expect(executionCount).toBe(2); // Function executed again
|
||||
expect(thirdResult.value).toBe("ttl-computation-2");
|
||||
expect(thirdResult).not.toEqual(firstResult);
|
||||
logger.info(`✅ Cache miss after TTL expiry confirmed: ${thirdResult.value}`);
|
||||
|
||||
// Verify the key was automatically removed by Redis TTL
|
||||
const redis = cacheService.getRedisClient();
|
||||
if (redis) {
|
||||
// The old key should be gone, but there might be a new one from the third call
|
||||
const currentKeys = await redis.keys(`fb:cache:${key}*`);
|
||||
logger.info(`Current cache keys: ${currentKeys.length > 0 ? currentKeys.join(", ") : "none"}`);
|
||||
// We expect either 0 keys (if TTL expired) or 1 key (new one from third call)
|
||||
expect(currentKeys.length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
logger.info("✅ TTL expiry working correctly");
|
||||
}, 20000);
|
||||
|
||||
test("Concurrent cache operations: thread safety", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = "concurrent-test";
|
||||
let globalExecutionCount = 0;
|
||||
|
||||
const expensiveFunction = async (
|
||||
id: number
|
||||
): Promise<{ id: number; execution: number; timestamp: number }> => {
|
||||
globalExecutionCount++;
|
||||
// Simulate expensive operation with variable delay
|
||||
await delay(Math.random() * 50 + 10);
|
||||
return {
|
||||
id,
|
||||
execution: globalExecutionCount,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
// Clear any existing cache keys
|
||||
const redis = cacheService.getRedisClient();
|
||||
if (redis) {
|
||||
const existingKeys = await redis.keys(`fb:cache:${baseKey}*`);
|
||||
if (existingKeys.length > 0) {
|
||||
await redis.del(existingKeys);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Starting concurrent cache operations...");
|
||||
|
||||
// Create multiple concurrent operations on different keys
|
||||
const concurrentOperations = Array.from({ length: 10 }, async (_, i) => {
|
||||
const key = createCacheKey.environment.state(`${baseKey}-${i}`);
|
||||
|
||||
// Each "thread" makes the same call twice - first should miss, second should hit
|
||||
const firstCall = await cacheService!.withCache(() => expensiveFunction(i), key, 30000);
|
||||
const secondCall = await cacheService!.withCache(() => expensiveFunction(i), key, 30000);
|
||||
|
||||
return { i, firstCall, secondCall };
|
||||
});
|
||||
|
||||
const results = await Promise.all(concurrentOperations);
|
||||
|
||||
logger.info(`Completed ${results.length} concurrent operations`);
|
||||
|
||||
// Verify each operation behaved correctly
|
||||
results.forEach(({ i, firstCall, secondCall }) => {
|
||||
// First call should have executed the function
|
||||
expect(firstCall.id).toBe(i);
|
||||
|
||||
// Second call should return the cached value (identical to first)
|
||||
expect(secondCall).toEqual(firstCall);
|
||||
|
||||
logger.info(`Operation ${i}: first=${firstCall.execution}, second=${secondCall.execution} (cached)`);
|
||||
});
|
||||
|
||||
// Verify we executed exactly 10 functions (one per unique key)
|
||||
expect(globalExecutionCount).toBe(10);
|
||||
logger.info(
|
||||
`✅ Concurrent operations completed successfully - ${globalExecutionCount} function executions`
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
test("Different data types: serialization correctness", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const testCases = [
|
||||
{ name: "string", value: "Hello, World!" },
|
||||
{ name: "number", value: 42.5 },
|
||||
{ name: "boolean", value: true },
|
||||
{ name: "null", value: null },
|
||||
{ name: "array", value: [1, "two", { three: 3 }, null, true] },
|
||||
{
|
||||
name: "object",
|
||||
value: {
|
||||
id: 123,
|
||||
name: "Test Object",
|
||||
nested: {
|
||||
array: [1, 2, 3],
|
||||
date: new Date().toISOString(),
|
||||
bool: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complex",
|
||||
value: {
|
||||
users: [
|
||||
{ id: 1, name: "Alice", roles: ["admin", "user"] },
|
||||
{ id: 2, name: "Bob", roles: ["user"] },
|
||||
],
|
||||
metadata: {
|
||||
version: "1.0.0",
|
||||
created: new Date().toISOString(),
|
||||
features: {
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
audit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
logger.info(`Testing serialization for ${testCases.length} data types...`);
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const key = createCacheKey.environment.state(`serialization-${testCase.name}`);
|
||||
|
||||
logger.info(`Testing ${testCase.name} type...`);
|
||||
|
||||
// Set the value
|
||||
const setResult = await cacheService.set(key, testCase.value, 30000);
|
||||
expect(setResult.ok).toBe(true);
|
||||
|
||||
// Get the value back
|
||||
const getResult = await cacheService.get(key);
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (getResult.ok) {
|
||||
expect(getResult.data).toEqual(testCase.value);
|
||||
}
|
||||
|
||||
// Test through withCache as well
|
||||
let functionCalled = false;
|
||||
const cachedResult = await cacheService.withCache(
|
||||
async () => {
|
||||
functionCalled = true;
|
||||
return testCase.value;
|
||||
},
|
||||
key,
|
||||
30000
|
||||
);
|
||||
|
||||
// Should hit cache, not call function
|
||||
expect(functionCalled).toBe(false);
|
||||
expect(cachedResult).toEqual(testCase.value);
|
||||
|
||||
logger.info(`✅ ${testCase.name} serialization successful`);
|
||||
}
|
||||
|
||||
logger.info("✅ All data types serialized correctly");
|
||||
}, 20000);
|
||||
|
||||
test("Error handling: graceful degradation when operations fail", async () => {
|
||||
if (!isRedisAvailable || !cacheService) {
|
||||
logger.info("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Test with invalid TTL (should handle gracefully)
|
||||
const validKey = createCacheKey.environment.state("error-test");
|
||||
const invalidTtl = -1000; // Negative TTL should be invalid
|
||||
|
||||
logger.info("Testing error handling with invalid inputs...");
|
||||
|
||||
const setResult = await cacheService.set(validKey, "test", invalidTtl);
|
||||
expect(setResult.ok).toBe(false);
|
||||
if (!setResult.ok) {
|
||||
expect(setResult.error.code).toBeDefined();
|
||||
logger.info(`✅ Set with invalid TTL handled gracefully: ${setResult.error.code}`);
|
||||
}
|
||||
|
||||
// Test withCache error handling with invalid TTL
|
||||
let functionCalled = false;
|
||||
|
||||
const withCacheResult = await cacheService.withCache(
|
||||
async () => {
|
||||
functionCalled = true;
|
||||
return "test result";
|
||||
},
|
||||
validKey,
|
||||
invalidTtl
|
||||
);
|
||||
|
||||
// Function should still be called even if cache fails
|
||||
expect(functionCalled).toBe(true);
|
||||
expect(withCacheResult).toBe("test result");
|
||||
logger.info("✅ withCache gracefully degraded to function execution when cache failed");
|
||||
|
||||
logger.info("✅ Error handling tests completed successfully");
|
||||
}, 15000);
|
||||
});
|
||||
2
packages/cache/src/cache-keys.test.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { createCacheKey } from "./cache-keys";
|
||||
|
||||
describe("@formbricks/cache cacheKeys", () => {
|
||||
|
||||
4
packages/cache/src/client.test.ts
vendored
@@ -1,7 +1,7 @@
|
||||
import type { RedisClient } from "@/types/client";
|
||||
import { ErrorCode } from "@/types/error";
|
||||
import { createClient } from "redis";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { RedisClient } from "@/types/client";
|
||||
import { ErrorCode } from "@/types/error";
|
||||
import { createRedisClientFromEnv, getCacheService, resetCacheFactory } from "./client";
|
||||
|
||||
// Mock the redis module
|
||||
|
||||
4
packages/cache/src/client.ts
vendored
@@ -1,7 +1,7 @@
|
||||
import type { RedisClient } from "@/types/client";
|
||||
import { type CacheError, ErrorCode, type Result, err, ok } from "@/types/error";
|
||||
import { createClient } from "redis";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { RedisClient } from "@/types/client";
|
||||
import { type CacheError, ErrorCode, type Result, err, ok } from "@/types/error";
|
||||
import { CacheService } from "./service";
|
||||
|
||||
/**
|
||||
|
||||
85
packages/cache/src/service.test.ts
vendored
@@ -20,6 +20,7 @@ interface MockRedisClient {
|
||||
setEx: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
exists: ReturnType<typeof vi.fn>;
|
||||
ping: ReturnType<typeof vi.fn>;
|
||||
isReady: boolean;
|
||||
isOpen: boolean;
|
||||
}
|
||||
@@ -34,6 +35,7 @@ describe("CacheService", () => {
|
||||
setEx: vi.fn(),
|
||||
del: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
ping: vi.fn().mockResolvedValue("PONG"),
|
||||
isReady: true,
|
||||
isOpen: true,
|
||||
};
|
||||
@@ -469,6 +471,89 @@ describe("CacheService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRedisAvailable", () => {
|
||||
test("should return true when Redis is ready, open, and ping succeeds", async () => {
|
||||
mockRedis.ping.mockResolvedValue("PONG");
|
||||
|
||||
const result = await cacheService.isRedisAvailable();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRedis.ping).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should return false when Redis is not ready", async () => {
|
||||
mockRedis.isReady = false;
|
||||
mockRedis.ping.mockResolvedValue("PONG");
|
||||
|
||||
const result = await cacheService.isRedisAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedis.ping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return false when Redis is not open", async () => {
|
||||
mockRedis.isOpen = false;
|
||||
mockRedis.ping.mockResolvedValue("PONG");
|
||||
|
||||
const result = await cacheService.isRedisAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedis.ping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return false when Redis ping fails", async () => {
|
||||
mockRedis.ping.mockRejectedValue(new Error("Connection lost"));
|
||||
|
||||
const result = await cacheService.isRedisAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedis.ping).toHaveBeenCalledOnce();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) }, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- Testing error handling with any Error type
|
||||
"Redis ping failed during availability check"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false when ping times out", async () => {
|
||||
// Mock ping to hang indefinitely
|
||||
const hangingPromise = new Promise(() => {
|
||||
// This promise never resolves to simulate timeout
|
||||
});
|
||||
mockRedis.ping.mockImplementation(() => hangingPromise);
|
||||
|
||||
const result = await cacheService.isRedisAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedis.ping).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle different ping responses correctly", async () => {
|
||||
// Test with standard PONG response
|
||||
mockRedis.ping.mockResolvedValue("PONG");
|
||||
let result = await cacheService.isRedisAvailable();
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Test with custom ping message
|
||||
mockRedis.ping.mockResolvedValue("custom-message");
|
||||
result = await cacheService.isRedisAvailable();
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Test with empty response (still success if no error thrown)
|
||||
mockRedis.ping.mockResolvedValue("");
|
||||
result = await cacheService.isRedisAvailable();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should be async and return Promise<boolean>", async () => {
|
||||
mockRedis.ping.mockResolvedValue("PONG");
|
||||
|
||||
const result = cacheService.isRedisAvailable();
|
||||
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
expect(await result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withCache", () => {
|
||||
test("should return cached value when available", async () => {
|
||||
const key = "test:key" as CacheKey;
|
||||
|
||||
38
packages/cache/src/service.ts
vendored
@@ -1,9 +1,9 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { RedisClient } from "@/types/client";
|
||||
import { type CacheError, CacheErrorClass, ErrorCode, type Result, err, ok } from "@/types/error";
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { ZCacheKey } from "@/types/keys";
|
||||
import { ZTtlMs } from "@/types/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { validateInputs } from "./utils/validation";
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ export class CacheService {
|
||||
* @returns The Redis client instance or null if not ready
|
||||
*/
|
||||
getRedisClient(): RedisClient | null {
|
||||
if (!this.isRedisAvailable()) {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return null;
|
||||
}
|
||||
return this.redis;
|
||||
@@ -45,7 +45,7 @@ export class CacheService {
|
||||
*/
|
||||
async get<T>(key: CacheKey): Promise<Result<T | null, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisAvailable()) {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
code: ErrorCode.RedisConnectionError,
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export class CacheService {
|
||||
*/
|
||||
async exists(key: CacheKey): Promise<Result<boolean, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisAvailable()) {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
code: ErrorCode.RedisConnectionError,
|
||||
});
|
||||
@@ -121,7 +121,7 @@ export class CacheService {
|
||||
*/
|
||||
async set(key: CacheKey, value: unknown, ttlMs: number): Promise<Result<void, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisAvailable()) {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
code: ErrorCode.RedisConnectionError,
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export class CacheService {
|
||||
*/
|
||||
async del(keys: CacheKey[]): Promise<Result<void, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisAvailable()) {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
code: ErrorCode.RedisConnectionError,
|
||||
});
|
||||
@@ -192,7 +192,7 @@ export class CacheService {
|
||||
* @returns Cached value if present, otherwise fresh result from fn()
|
||||
*/
|
||||
async withCache<T>(fn: () => Promise<T>, key: CacheKey, ttlMs: number): Promise<T> {
|
||||
if (!this.isRedisAvailable()) {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return await fn();
|
||||
}
|
||||
|
||||
@@ -257,7 +257,29 @@ export class CacheService {
|
||||
}
|
||||
}
|
||||
|
||||
private isRedisAvailable(): boolean {
|
||||
/**
|
||||
* Check if Redis is available and healthy by testing connectivity with ping
|
||||
* @returns Promise<boolean> indicating if Redis is available and responsive
|
||||
*/
|
||||
async isRedisAvailable(): Promise<boolean> {
|
||||
if (!this.isRedisClientReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.withTimeout(this.redis.ping());
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Redis ping failed during availability check");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast synchronous check of Redis client state for internal use
|
||||
* @returns Boolean indicating if Redis client is ready and connected
|
||||
*/
|
||||
private isRedisClientReady(): boolean {
|
||||
return this.redis.isReady && this.redis.isOpen;
|
||||
}
|
||||
}
|
||||
|
||||
2
packages/cache/src/utils/key.test.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { makeCacheKey } from "./key";
|
||||
|
||||
describe("@formbricks/cache utils/key", () => {
|
||||
|
||||
2
packages/cache/src/utils/key.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
|
||||
/**
|
||||
* Helper function to create cache keys with runtime validation
|
||||
|
||||
2
packages/cache/src/utils/validation.test.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import { ErrorCode } from "@/types/error";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ErrorCode } from "@/types/error";
|
||||
import { validateInputs } from "./validation";
|
||||
|
||||
// Mock logger
|
||||
|
||||
4
packages/cache/src/utils/validation.ts
vendored
@@ -1,7 +1,7 @@
|
||||
import type { CacheError, Result } from "@/types/error";
|
||||
import { ErrorCode, err, ok } from "@/types/error";
|
||||
import type { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { CacheError, Result } from "@/types/error";
|
||||
import { ErrorCode, err, ok } from "@/types/error";
|
||||
|
||||
/**
|
||||
* Generic validation function using Zod schemas with Result types
|
||||
|
||||
@@ -14,6 +14,7 @@ module.exports = {
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@formbricks/(.*)$",
|
||||
"^~/(.*)$",
|
||||
"^@/(.*)$",
|
||||
"^[./]",
|
||||
],
|
||||
importOrderSeparation: false,
|
||||
|
||||
@@ -82,8 +82,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client without credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with missing access key (IAM role scenario)
|
||||
test("should create S3 client when access key is missing (IAM role authentication)", async () => {
|
||||
// Mock constants with missing access key - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
@@ -105,8 +105,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client without secret key (IAM role authentication)", async () => {
|
||||
// Mock constants with missing secret key (IAM role scenario)
|
||||
test("should create S3 client when secret key is missing (IAM role authentication)", async () => {
|
||||
// Mock constants with missing secret key - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_SECRET_KEY: undefined,
|
||||
@@ -128,8 +128,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client without any credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with no credentials (full IAM role scenario)
|
||||
test("should create S3 client when both credentials are missing (IAM role authentication)", async () => {
|
||||
// Mock constants with no credentials - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
@@ -152,8 +152,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client with empty string credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with empty string credentials (treated as undefined)
|
||||
test("should create S3 client when credentials are empty strings (IAM role authentication)", async () => {
|
||||
// Mock constants with empty string credentials - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: "",
|
||||
@@ -176,8 +176,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client with mixed empty and undefined credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with mixed empty and undefined (both treated as missing)
|
||||
test("should create S3 client when mixed empty and undefined credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with mixed empty and undefined - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: "",
|
||||
@@ -200,33 +200,6 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle empty endpoint URL", async () => {
|
||||
// Mock constants with empty endpoint URL
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ENDPOINT_URL: "",
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
credentials: {
|
||||
accessKeyId: mockConstants.S3_ACCESS_KEY,
|
||||
secretAccessKey: mockConstants.S3_SECRET_KEY,
|
||||
},
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: "",
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when region is missing (uses AWS SDK defaults)", async () => {
|
||||
// Mock constants with missing region - should still work
|
||||
vi.doMock("./constants", () => ({
|
||||
@@ -296,6 +269,33 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle empty endpoint URL", async () => {
|
||||
// Mock constants with empty endpoint URL
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ENDPOINT_URL: "",
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
credentials: {
|
||||
accessKeyId: mockConstants.S3_ACCESS_KEY,
|
||||
secretAccessKey: mockConstants.S3_SECRET_KEY,
|
||||
},
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: "",
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return unknown error when S3Client constructor throws", async () => {
|
||||
// Provide valid credentials so we reach the constructor path
|
||||
vi.doMock("./constants", () => ({
|
||||
@@ -353,7 +353,7 @@ describe("client.ts", () => {
|
||||
});
|
||||
|
||||
test("should return undefined when creating from env fails and no client provided", async () => {
|
||||
// Mock constants with missing required field (bucket name only)
|
||||
// Mock constants with missing bucket name (the only required field)
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
|
||||
150
packages/surveys/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
## Overview
|
||||
|
||||
The `@formbricks/surveys` package provides a complete survey rendering system built with Preact/React. It features automated translation management through Lingo.dev.
|
||||
|
||||
## Features
|
||||
|
||||
- **Survey Components**: Complete set of survey question types and UI components
|
||||
- **Internationalization**: Built with i18next and react-i18next
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Testing**: Comprehensive test coverage with Vitest
|
||||
- **Lightweight**: Built with Preact for optimal bundle size
|
||||
- **Multi-language Support**: Supports 10+ languages with automated translation generation
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
```text
|
||||
packages/surveys/
|
||||
├── locales/ # Translation files
|
||||
│ ├── en.json # Source translations (English)
|
||||
│ ├── de.json # Generated translations (German)
|
||||
│ ├── fr.json # Generated translations (French)
|
||||
│ └── ... # Other target languages
|
||||
├── i18n.json # lingo.dev configuration
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── buttons/ # Survey navigation buttons
|
||||
│ │ ├── general/ # Core survey components
|
||||
│ │ ├── i18n/
|
||||
│ │ │ └── provider.tsx # i18n provider component
|
||||
│ │ ├── icons/ # Icon components
|
||||
│ │ ├── questions/ # Question type components
|
||||
│ │ └── wrappers/ # Layout wrappers
|
||||
│ ├── lib/
|
||||
│ │ ├── i18n.config.ts # i18next configuration
|
||||
│ │ ├── i18n-utils.ts # Utility functions
|
||||
│ │ └── ... # Other utilities
|
||||
│ ├── styles/ # CSS styles
|
||||
│ └── types/ # TypeScript types
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Setting Up Automated Translations
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Lingo.dev](https://Lingo.dev) API key
|
||||
- Access to the Formbricks team on Lingo.dev
|
||||
|
||||
### Step-by-Step Setup
|
||||
|
||||
1. **Join the Formbricks Team**
|
||||
|
||||
- Join the Formbricks team on Lingo.dev
|
||||
|
||||
2. **Get Your API Key**
|
||||
|
||||
- In the sidebar, go to **Projects** and open the default project
|
||||
- Navigate to the **Settings** tab
|
||||
- Copy the API key
|
||||
|
||||
3. **Configure Environment Variables**
|
||||
|
||||
In the surveys package directory, create a `.env` file:
|
||||
|
||||
```bash
|
||||
# packages/surveys/.env
|
||||
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
|
||||
```
|
||||
|
||||
4. **Generate Translations**
|
||||
|
||||
Run the translation generation script:
|
||||
|
||||
```bash
|
||||
# From the root of the repo or from within the surveys package
|
||||
pnpm run i18n:generate
|
||||
```
|
||||
|
||||
This will execute the auto-translate script and update translation files if needed.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding New Translation Keys
|
||||
|
||||
1. **Update Source File**: Add new keys to `packages/surveys/locales/en.json`
|
||||
2. **Generate Translations**: Run `pnpm run i18n:generate`
|
||||
3. **Update Components**: Use the new translation keys in your components with `useTranslation` hook
|
||||
4. **Test**: Verify translations work across all supported languages
|
||||
|
||||
### Updating Existing Translations
|
||||
|
||||
1. **Update Target File**: Update the translation keys in the target language file (`packages/surveys/locales/<target-language>.json`)
|
||||
2. **Test**: Verify translations work across all supported languages
|
||||
3. You don't need to run the `i18n:generate` command as it is only required when the source language is updated.
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
#### 1. Update lingo.dev Configuration
|
||||
|
||||
Edit `packages/surveys/i18n.json` to include new target languages:
|
||||
|
||||
```json
|
||||
{
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["de", "it", ...otherLanguages, "new-lang"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update i18n Configuration
|
||||
|
||||
Modify `packages/surveys/src/lib/i18n.config.ts`:
|
||||
|
||||
```tsx
|
||||
// Add new import
|
||||
import newLangTranslations from "../../locales/new-lang.json";
|
||||
|
||||
i18n
|
||||
.use(ICU)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "de", ...otherLanguages, "new-lang"],
|
||||
resources: {
|
||||
// ... existing resources
|
||||
"new-lang": { translation: newLangTranslations },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. Generate Translation Files
|
||||
|
||||
Run the translation generation command:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:generate
|
||||
```
|
||||
|
||||
This will create new translation files in the `locales/` directory for each target language.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` - Start development build
|
||||
- `pnpm build` - Build for production
|
||||
- `pnpm test` - Run tests
|
||||
- `pnpm test:coverage` - Run tests with coverage
|
||||
- `pnpm i18n:generate` - Generate translations using Lingo.dev
|
||||
- `pnpm lint` - Lint and fix code
|
||||
@@ -31,7 +31,7 @@ export type TUserEmail = z.infer<typeof ZUserEmail>;
|
||||
|
||||
export const ZUserPassword = z
|
||||
.string()
|
||||
.min(8)
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(128, { message: "Password must be 128 characters or less" })
|
||||
.regex(/^(?=.*[A-Z])(?=.*\d).*$/);
|
||||
|
||||
|
||||