diff --git a/.github/workflows/helm-chart-validation.yml b/.github/workflows/helm-chart-validation.yml new file mode 100644 index 000000000000..b8051ab7223f --- /dev/null +++ b/.github/workflows/helm-chart-validation.yml @@ -0,0 +1,76 @@ +name: Helm Chart Validation + +on: + workflow_call: + +permissions: + contents: read + +jobs: + validate: + name: Validate Helm Chart + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 + with: + version: v3.15.4 + + - name: Validate chart renders + shell: bash + run: | + set -euo pipefail + + chart="charts/formbricks" + render_dir="$(mktemp -d)" + trap 'rm -rf "$render_dir"' EXIT + + helm lint "$chart" --set formbricks.webappUrl=https://qa.example.com + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --show-only templates/migration-job.yaml > "$render_dir/default.yaml" + + grep -q 'packages/database/dist/scripts/wait-for-database.js' "$render_dir/default.yaml" + grep -q 'packages/database/dist/scripts/apply-migrations.js' "$render_dir/default.yaml" + grep -q 'argocd.argoproj.io/sync-wave: "-1"' "$render_dir/default.yaml" + test "$(grep -c '^ image: ' "$render_dir/default.yaml")" -eq 2 + test "$(grep '^ image: ' "$render_dir/default.yaml" | sort -u | wc -l | tr -d ' ')" -eq 1 + test "$(grep -c 'name: formbricks-app-secrets' "$render_dir/default.yaml")" -eq 2 + ! grep -q '^ env:$' "$render_dir/default.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set migration.waitForDatabase.enabled=false \ + --show-only templates/migration-job.yaml > "$render_dir/disabled.yaml" + ! grep -q 'wait-for-database' "$render_dir/disabled.yaml" + grep -q 'packages/database/dist/scripts/apply-migrations.js' "$render_dir/disabled.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set postgresql.enabled=false \ + --set-string postgresql.externalDatabaseUrl=postgresql://user:password@external-postgresql:5432/formbricks \ + --show-only templates/migration-job.yaml > "$render_dir/external.yaml" + grep -q 'packages/database/dist/scripts/wait-for-database.js' "$render_dir/external.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set-string deployment.env.MIGRATE_DATABASE_URL=postgresql://user:password@migrate-postgresql:5432/formbricks \ + --show-only templates/migration-job.yaml > "$render_dir/migrate-url.yaml" + test "$(grep -c 'name: MIGRATE_DATABASE_URL' "$render_dir/migrate-url.yaml")" -eq 2 + test "$(grep -c '^ env:' "$render_dir/migrate-url.yaml")" -eq 2 + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --show-only charts/postgresql/templates/primary/statefulset.yaml > "$render_dir/postgresql.yaml" + grep -q 'argocd.argoproj.io/sync-wave: "-2"' "$render_dir/postgresql.yaml" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 87371648d9b6..a3c53bc2121a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -33,6 +33,10 @@ jobs: uses: ./.github/workflows/build-web.yml secrets: inherit + helm-chart-validation: + name: Validate Helm Chart + uses: ./.github/workflows/helm-chart-validation.yml + e2e-test: name: Run E2E Tests uses: ./.github/workflows/e2e.yml @@ -40,7 +44,7 @@ jobs: required: name: PR Check Summary - needs: [lint, test, build, e2e-test] + needs: [lint, test, build, helm-chart-validation, e2e-test] if: always() runs-on: ubuntu-latest permissions: diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx index 0984d41650a4..2657c752656f 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx @@ -397,7 +397,11 @@ export const MainNavigation = ({
{/* Logo and Toggle */} -
+
{!isCollapsed && ( -
  • +
  • {disabled ? ( -
    {children}
    +
    {children}
    ) : ( - {children} + + {children} + )}
  • diff --git a/apps/web/modules/account/lib/better-auth-account-deletion-request.integration.test.ts b/apps/web/modules/account/lib/better-auth-account-deletion-request.integration.test.ts index 7a7aeaec667a..a28f30dc7852 100644 --- a/apps/web/modules/account/lib/better-auth-account-deletion-request.integration.test.ts +++ b/apps/web/modules/account/lib/better-auth-account-deletion-request.integration.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { resetDb } from "@/integration/reset-db"; +import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants"; import { auth } from "@/modules/auth/lib/auth"; import { sendDeleteAccountConfirmationEmail } from "@/modules/email"; import { requestSsoAccountDeletionEmail } from "./better-auth-account-deletion-request"; @@ -11,6 +12,20 @@ import { requestSsoAccountDeletionEmail } from "./better-auth-account-deletion-r const { getSessionMock } = vi.hoisted(() => ({ getSessionMock: vi.fn() })); vi.mock("@/modules/auth/lib/session", () => ({ getSession: getSessionMock })); +// Toggle IS_FORMBRICKS_CLOUD per test to cover both post-deletion redirect targets; every other +// constant (WEBAPP_URL, etc.) stays real so the Better Auth harness is untouched (auth.ts does not +// read IS_FORMBRICKS_CLOUD). +const { constantsOverrides } = vi.hoisted(() => ({ constantsOverrides: { isFormbricksCloud: false } })); +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get IS_FORMBRICKS_CLOUD() { + return constantsOverrides.isFormbricksCloud; + }, + }; +}); + // @/modules/email is mocked in integration/setup.ts (captures mail instead of hitting SMTP); grab the // auto-mocked sendDeleteAccountConfirmationEmail to assert the action calls it with the callback link. const sendDeleteAccountConfirmationEmailMock = vi.mocked(sendDeleteAccountConfirmationEmail); @@ -39,6 +54,7 @@ const createVerifiedUser = async (email: string, password: string): Promise { await resetDb(); vi.clearAllMocks(); + constantsOverrides.isFormbricksCloud = false; }); describe("requestSsoAccountDeletionEmail (real Postgres)", () => { @@ -65,7 +81,8 @@ describe("requestSsoAccountDeletionEmail (real Postgres)", () => { const mailArgs = sendDeleteAccountConfirmationEmailMock.mock.calls[0][0]; expect(mailArgs.email).toBe(email); expect(mailArgs.deleteLink).toContain("/api/auth/delete-user/callback?token="); - expect(mailArgs.deleteLink).toContain("callbackURL=/"); + // Self-hosted (IS_FORMBRICKS_CLOUD is false under test): the callback returns to the login page. + expect(mailArgs.deleteLink).toContain(`callbackURL=${encodeURIComponent("/auth/login")}`); const token = new URL(mailArgs.deleteLink).searchParams.get("token"); expect(token).toBeTruthy(); @@ -87,6 +104,23 @@ describe("requestSsoAccountDeletionEmail (real Postgres)", () => { expect(await prisma.user.findUnique({ where: { id: userId } })).toBeNull(); }); + test("on Formbricks Cloud, the emailed link returns to the account-deletion survey (ENG-1780)", async () => { + constantsOverrides.isFormbricksCloud = true; + const email = "ssocloud@example.com"; + const userId = await createVerifiedUser(email, "Passw0rd!"); + await prisma.user.update({ where: { id: userId }, data: { identityProvider: "google" } }); + getSessionMock.mockResolvedValue({ user: { id: userId, email } }); + + await requestSsoAccountDeletionEmail(); + + expect(sendDeleteAccountConfirmationEmailMock).toHaveBeenCalledTimes(1); + const mailArgs = sendDeleteAccountConfirmationEmailMock.mock.calls[0][0]; + // Cloud: the callback redirects to the offboarding survey instead of the login page. + expect(mailArgs.deleteLink).toContain( + `callbackURL=${encodeURIComponent(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL)}` + ); + }); + test("rejects a credential (email-identity) user — they must confirm with their password", async () => { const email = "credrequest@example.com"; const userId = await createVerifiedUser(email, "Passw0rd!"); // identityProvider defaults to "email" diff --git a/apps/web/modules/account/lib/better-auth-account-deletion-request.ts b/apps/web/modules/account/lib/better-auth-account-deletion-request.ts index b985225351ac..e03f8a2f1d2a 100644 --- a/apps/web/modules/account/lib/better-auth-account-deletion-request.ts +++ b/apps/web/modules/account/lib/better-auth-account-deletion-request.ts @@ -3,7 +3,8 @@ import crypto from "node:crypto"; import { prisma } from "@formbricks/database"; import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; import type { TUserLocale } from "@formbricks/types/user"; -import { WEBAPP_URL } from "@/lib/constants"; +import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants"; +import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants"; import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth"; import { auth } from "@/modules/auth/lib/auth"; import { getSession } from "@/modules/auth/lib/session"; @@ -66,7 +67,13 @@ export const requestSsoAccountDeletionEmail = async (): Promise => { expiresAt: new Date(Date.now() + DELETE_ACCOUNT_LINK_VALIDITY_MS), }); - const deleteLink = `${WEBAPP_URL}/api/auth/delete-user/callback?token=${token}&callbackURL=/`; + // Match the credential delete path's post-deletion redirect (DeleteAccountModal): survey on + // Formbricks Cloud, /auth/login otherwise. Better Auth's delete-user/callback only accepts + // same-origin callbackURLs (trustedOrigins); the survey is served from the Cloud app's own origin, + // so it passes there. (It is rejected when WEBAPP_URL differs from the survey origin — e.g. a local + // build with the Cloud flag forced on.) + const callbackURL = IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login"; + const deleteLink = `${WEBAPP_URL}/api/auth/delete-user/callback?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`; await sendDeleteAccountConfirmationEmail({ email, diff --git a/apps/web/modules/ee/analysis/charts/actions.test.ts b/apps/web/modules/ee/analysis/charts/actions.test.ts index 2c76a6c1e3ca..6bd56c93ed46 100644 --- a/apps/web/modules/ee/analysis/charts/actions.test.ts +++ b/apps/web/modules/ee/analysis/charts/actions.test.ts @@ -151,6 +151,7 @@ describe("chart Cube actions", () => { measures: ["FeedbackRecords.count"], dimensions: ["FeedbackRecords.sourceType"], }, + name: "Responses by Source Type", }); const result = await generateAIChartAction({ @@ -173,6 +174,7 @@ describe("chart Cube actions", () => { dimensions: ["FeedbackRecords.sourceType"], }, data: [{ "FeedbackRecords.count": 1 }], + suggestedName: "Responses by Source Type", }); expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({ query: { diff --git a/apps/web/modules/ee/analysis/charts/actions.ts b/apps/web/modules/ee/analysis/charts/actions.ts index d53cbb18ea35..5940381e6db4 100644 --- a/apps/web/modules/ee/analysis/charts/actions.ts +++ b/apps/web/modules/ee/analysis/charts/actions.ts @@ -321,7 +321,7 @@ export const generateAIChartAction = authenticatedActionClient source: "charts.generateAIChartAction", }); - const { chartType, query } = await generateAIChartQuery({ + const { chartType, query, name } = await generateAIChartQuery({ organizationId, prompt: parsedInput.prompt, }); @@ -341,6 +341,8 @@ export const generateAIChartAction = authenticatedActionClient query: validatedQuery, chartType, data: Array.isArray(data) ? data : [], + // Prefills the chart-name input (only when the user hasn't typed a name). + suggestedName: name, }; } ); diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index e1e5532451af..137983655e85 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -9,11 +9,12 @@ import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/po import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, - CHART_NOT_ENRICHED_COLOR, PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, formatCellValue, formatXAxisTick, + getSemanticDimensionColor, + getSentimentMeasureColor, pivotMeasuresToCategories, preparePieData, } from "@/modules/ee/analysis/charts/lib/chart-utils"; @@ -22,7 +23,6 @@ import { formatCubeColumnHeader, getMeasureAxisLabel, getTranslatedDimensionValueLabel, - isNotEnrichedDimensionValue, sortMeasureIdsForCategoryAxis, sortRowsByEnumDimension, } from "@/modules/ee/analysis/lib/schema-definition"; @@ -176,12 +176,14 @@ export function ChartRenderer({ chartType, data, query }: Readonly [ key, { label: formatCubeColumnHeader(key, t), - color: CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length], + color: getSentimentMeasureColor(key) ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length], }, ]) ); @@ -237,9 +239,9 @@ export function ChartRenderer({ chartType, data, query }: Readonly ({ ...row, - fill: isNotEnrichedDimensionValue(xAxisKey, row[xAxisKey]) - ? CHART_NOT_ENRICHED_COLOR - : CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], + fill: + getSemanticDimensionColor(xAxisKey, row[xAxisKey]) ?? + CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], })); return ( @@ -255,23 +257,19 @@ export function ChartRenderer({ chartType, data, query }: Readonly - {dataKeys.map((key, i) => { - const fallbackColor = - chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]; - return ( - - {!isMultiMeasure && ( - formatCellValue(value)} - /> - )} - - ); - })} + {dataKeys.map((key) => ( + + {!isMultiMeasure && ( + formatCellValue(value)} + /> + )} + + ))} ); } @@ -288,8 +286,8 @@ export function ChartRenderer({ chartType, data, query }: Readonly - {dataKeys.map((key, i) => { - const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]; + {dataKeys.map((key) => { + const color = chartConfig[key]?.color; return ( @@ -298,8 +296,8 @@ export function ChartRenderer({ chartType, data, query }: Readonly - {dataKeys.map((key, i) => { - const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]; + {dataKeys.map((key) => { + const color = chartConfig[key]?.color; return ( - {dataKeys.map((key, i) => ( + {dataKeys.map((key) => ( { expect(result.current.chartName).toBe("Custom Chart"); }); + test("replaces a previously suggested name when the chart is regenerated", async () => { + const { result } = renderHook(() => useChartDialog(baseProps)); + + await act(async () => { + result.current.handleChartGenerated({ + ...sampleChartData, + suggestedName: "Responses by Source", + }); + }); + + await act(async () => { + result.current.handleChartGenerated({ + ...sampleChartData, + suggestedName: "NPS by Week", + }); + }); + + expect(result.current.chartName).toBe("NPS by Week"); + }); + + test("keeps a user-edited name when the chart is regenerated", async () => { + const { result } = renderHook(() => useChartDialog(baseProps)); + + await act(async () => { + result.current.handleChartGenerated({ + ...sampleChartData, + suggestedName: "Responses by Source", + }); + }); + + await act(async () => { + result.current.setChartName("My Custom Name"); + }); + + await act(async () => { + result.current.handleChartGenerated({ + ...sampleChartData, + suggestedName: "NPS by Week", + }); + }); + + expect(result.current.chartName).toBe("My Custom Name"); + }); + test("preserves custom chart name when user types before delayed AI response completes", async () => { const { result } = renderHook(() => useChartDialog(baseProps)); @@ -384,6 +428,27 @@ describe("useChartDialog", () => { expect(result.current.chartName).toBe("User Typed Name"); }); + + test("preserves the user's name even when invoked through a stale closure", async () => { + const { result } = renderHook(() => useChartDialog(baseProps)); + + // Captured before the user types — like a consumer holding the callback across renders + // while the AI request is in flight. + const staleHandleChartGenerated = result.current.handleChartGenerated; + + await act(async () => { + result.current.setChartName("User Typed Name"); + }); + + await act(async () => { + staleHandleChartGenerated({ + ...sampleChartData, + suggestedName: "AI Generated Name", + }); + }); + + expect(result.current.chartName).toBe("User Typed Name"); + }); }); describe("savedChartName", () => { diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts index 6149aff79fcf..b41717af8efa 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; @@ -60,6 +60,9 @@ export function useChartDialog({ const [chartLoadError, setChartLoadError] = useState(null); const [currentChartId, setCurrentChartId] = useState(chartId); const [selectedDirectoryId, setSelectedDirectoryId] = useState(directories?.[0]?.id ?? null); + // Last name we prefilled from a suggestion; lets a regenerate replace its own + // stale suggestion without ever clobbering a name the user typed. + const lastSuggestedNameRef = useRef(null); useEffect(() => { let cancelled = false; @@ -87,6 +90,7 @@ export function useChartDialog({ setChartData(null); setChartName(""); setSavedChartName(""); + lastSuggestedNameRef.current = null; setSelectedChartType(undefined); setCurrentChartId(undefined); setSelectedDirectoryId(directories?.[0]?.id ?? null); @@ -169,8 +173,15 @@ export function useChartDialog({ const handleChartGenerated = (data: AnalyticsResponse) => { setChartData(data); setSelectedChartType(data.chartType); - if (data.suggestedName) { - setChartName((prev) => (prev.trim() ? prev : data.suggestedName!)); + const suggestedName = data.suggestedName?.trim(); + if (suggestedName) { + // Functional updater: the AI response lands async, so a closure over chartName could be + // stale and clobber a name the user typed while the request was in flight. + setChartName((prev) => { + if (prev.trim() && prev !== lastSuggestedNameRef.current) return prev; + lastSuggestedNameRef.current = suggestedName; + return suggestedName; + }); } }; @@ -367,6 +378,7 @@ export function useChartDialog({ setChartData(null); setChartName(""); setSavedChartName(""); + lastSuggestedNameRef.current = null; setSelectedChartType(undefined); setCurrentChartId(undefined); setChartLoadError(null); diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts index e008215fc51e..322728680b3d 100644 --- a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts +++ b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts @@ -15,6 +15,8 @@ const CUBE_NAME = "FeedbackRecords"; const DEFAULT_MEASURE = `${CUBE_NAME}.count`; const AI_CHART_GENERATION_TIMEOUT_MS = 30_000; const AI_CHART_GENERATION_MAX_OUTPUT_TOKENS = 1024; +// Matches the maxLength of the chart-name input and the persisted chart name. +const MAX_CHART_NAME_LENGTH = 255; const toEnumTuple = (values: readonly string[]): [string, ...string[]] => { if (values.length === 0) { @@ -70,6 +72,10 @@ const ZFilter = z }); export const ZAIQueryResponse = z.object({ + name: z + .string() + .nullable() + .describe("Short, descriptive chart name reflecting the user's request (max 255 characters)"), measures: z.array(ZMeasureId), dimensions: z.array(ZDimensionId).nullable(), timeDimensions: z @@ -90,6 +96,8 @@ type AIQueryResponse = z.infer; export type AIChartQueryResult = { chartType: TChartType; query: TChartQuery; + /** AI-suggested chart name; omitted when the model returns none. */ + name?: string; }; type GenerateAIChartQueryInput = { @@ -159,5 +167,12 @@ const normalizeChartQuery = (output: AIQueryResponse): AIChartQueryResult => { })); } - return { chartType: output.chartType, query }; + const result: AIChartQueryResult = { chartType: output.chartType, query }; + + const name = output.name?.trim(); + if (name) { + result.name = name.slice(0, MAX_CHART_NAME_LENGTH); + } + + return result; }; diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts index b2473fb3116a..23b36dc175cd 100644 --- a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts @@ -30,6 +30,7 @@ describe("generateAIChartQuery", () => { test("returns the AI-generated chart type and normalized query for a clean response", async () => { mocks.generateOrganizationAIObject.mockResolvedValueOnce({ object: { + name: "Responses by Source Type", measures: ["FeedbackRecords.count"], dimensions: ["FeedbackRecords.sourceType"], timeDimensions: null, @@ -45,6 +46,7 @@ describe("generateAIChartQuery", () => { expect(result).toEqual({ chartType: "bar", + name: "Responses by Source Type", query: { measures: ["FeedbackRecords.count"], dimensions: ["FeedbackRecords.sourceType"], @@ -65,6 +67,7 @@ describe("generateAIChartQuery", () => { test("maps NPS score prompts to the NPS score measure", async () => { mocks.generateOrganizationAIObject.mockResolvedValueOnce({ object: { + name: null, measures: ["FeedbackRecords.npsScore"], dimensions: null, timeDimensions: null, @@ -87,6 +90,7 @@ describe("generateAIChartQuery", () => { test("falls back to the total count measure when the AI returns no measures", async () => { mocks.generateOrganizationAIObject.mockResolvedValueOnce({ object: { + name: null, measures: [], dimensions: null, timeDimensions: null, @@ -106,6 +110,7 @@ describe("generateAIChartQuery", () => { test("normalizes filters and time dimensions, dropping null-only optional fields", async () => { mocks.generateOrganizationAIObject.mockResolvedValueOnce({ object: { + name: null, measures: ["FeedbackRecords.count"], dimensions: null, timeDimensions: [ @@ -137,6 +142,7 @@ describe("generateAIChartQuery", () => { test("rejects value-based filters without a non-empty values array", () => { const result = ZAIQueryResponse.safeParse({ + name: null, measures: ["FeedbackRecords.count"], dimensions: null, timeDimensions: null, @@ -153,6 +159,7 @@ describe("generateAIChartQuery", () => { test("rejects valueless filters that include values", () => { const result = ZAIQueryResponse.safeParse({ + name: null, measures: ["FeedbackRecords.count"], dimensions: null, timeDimensions: null, @@ -167,6 +174,7 @@ describe("generateAIChartQuery", () => { test("allows valueless filters with omitted values", () => { const result = ZAIQueryResponse.safeParse({ + name: null, measures: ["FeedbackRecords.count"], dimensions: null, timeDimensions: null, @@ -177,6 +185,46 @@ describe("generateAIChartQuery", () => { expect(result.success).toBe(true); }); + test("trims the AI-suggested name and caps it at 255 characters", async () => { + mocks.generateOrganizationAIObject.mockResolvedValueOnce({ + object: { + name: ` ${"a".repeat(300)} `, + measures: ["FeedbackRecords.count"], + dimensions: null, + timeDimensions: null, + chartType: "bar", + filters: null, + }, + }); + + const result = await generateAIChartQuery({ + organizationId: "organization-1", + prompt: "responses", + }); + + expect(result.name).toBe("a".repeat(255)); + }); + + test("omits the name when the AI returns a blank name", async () => { + mocks.generateOrganizationAIObject.mockResolvedValueOnce({ + object: { + name: " ", + measures: ["FeedbackRecords.count"], + dimensions: null, + timeDimensions: null, + chartType: "bar", + filters: null, + }, + }); + + const result = await generateAIChartQuery({ + organizationId: "organization-1", + prompt: "responses", + }); + + expect(result).not.toHaveProperty("name"); + }); + test("converts AI structured-output failures into a prompt error", async () => { mocks.generateOrganizationAIObject.mockRejectedValueOnce( new NoObjectGeneratedError({ diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts index 626fc1336e14..69198fef932f 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts @@ -1,12 +1,16 @@ import { describe, expect, test } from "vitest"; +import { SENTIMENT_VALUE_ORDER } from "@/modules/ee/analysis/lib/schema-definition"; import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, CHART_NOT_ENRICHED_COLOR, + CHART_SENTIMENT_COLORS, PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, formatCellValue, formatXAxisTick, + getSemanticDimensionColor, + getSentimentMeasureColor, pivotMeasuresToCategories, preparePieData, resolveChartType, @@ -61,7 +65,7 @@ describe("chart-utils", () => { expect(result!.colors[1]).toBe(CHART_MEASURE_COLORS[1]); }); - test("greys the not-enriched slice and hands palette colours only to enriched slices", () => { + test("greys the not-enriched slice and colours sentiment slices semantically", () => { const nameKey = "FeedbackRecords.sentiment"; const data = [ { [nameKey]: "", count: 20 }, // biggest → sorted first → not enriched @@ -70,8 +74,23 @@ describe("chart-utils", () => { ]; const result = preparePieData(data, "count", nameKey); expect(result).not.toBeNull(); - // gray for the empty (not-enriched) slice; the palette is not consumed by it + // gray for the empty (not-enriched) slice; sentiment slices are keyed by enum value expect(result!.colors[0]).toBe(CHART_NOT_ENRICHED_COLOR); + expect(result!.colors[1]).toBe(CHART_SENTIMENT_COLORS.positive); + expect(result!.colors[2]).toBe(CHART_SENTIMENT_COLORS.negative); + }); + + test("falls back to the palette for unknown sentiment tokens without letting semantic slices consume hues", () => { + const nameKey = "FeedbackRecords.sentiment"; + const data = [ + { [nameKey]: "very_positive", count: 9 }, + { [nameKey]: "unexpected_token", count: 5 }, + { [nameKey]: "another_unknown", count: 3 }, + ]; + const result = preparePieData(data, "count", nameKey); + expect(result).not.toBeNull(); + expect(result!.colors[0]).toBe(CHART_SENTIMENT_COLORS.very_positive); + // palette indices start at 0 for the non-semantic slices expect(result!.colors[1]).toBe(CHART_MEASURE_COLORS[0]); expect(result!.colors[2]).toBe(CHART_MEASURE_COLORS[1]); }); @@ -144,6 +163,50 @@ describe("chart-utils", () => { }); }); + describe("getSemanticDimensionColor", () => { + const sentimentDim = "FeedbackRecords.sentiment"; + + test("maps every sentiment enum value to its semantic color", () => { + for (const value of SENTIMENT_VALUE_ORDER) { + expect(getSemanticDimensionColor(sentimentDim, value)).toBe(CHART_SENTIMENT_COLORS[value]); + } + }); + + test("returns the not-enriched gray for empty enrichment values", () => { + expect(getSemanticDimensionColor(sentimentDim, "")).toBe(CHART_NOT_ENRICHED_COLOR); + expect(getSemanticDimensionColor(sentimentDim, null)).toBe(CHART_NOT_ENRICHED_COLOR); + expect(getSemanticDimensionColor("FeedbackRecords.emotions", "")).toBe(CHART_NOT_ENRICHED_COLOR); + }); + + test("returns undefined for unknown tokens and non-sentiment dimensions", () => { + expect(getSemanticDimensionColor(sentimentDim, "unexpected_token")).toBeUndefined(); + // emotions keep the generic palette (only their empty bucket is semantic) + expect(getSemanticDimensionColor("FeedbackRecords.emotions", "joy")).toBeUndefined(); + expect(getSemanticDimensionColor("FeedbackRecords.language", "positive")).toBeUndefined(); + }); + }); + + describe("getSentimentMeasureColor", () => { + test("maps every sentiment count measure to the matching bucket color", () => { + expect(getSentimentMeasureColor("FeedbackRecords.veryNegativeCount")).toBe( + CHART_SENTIMENT_COLORS.very_negative + ); + expect(getSentimentMeasureColor("FeedbackRecords.negativeCount")).toBe(CHART_SENTIMENT_COLORS.negative); + expect(getSentimentMeasureColor("FeedbackRecords.neutralCount")).toBe(CHART_SENTIMENT_COLORS.neutral); + expect(getSentimentMeasureColor("FeedbackRecords.positiveCount")).toBe(CHART_SENTIMENT_COLORS.positive); + expect(getSentimentMeasureColor("FeedbackRecords.veryPositiveCount")).toBe( + CHART_SENTIMENT_COLORS.very_positive + ); + expect(getSentimentMeasureColor("FeedbackRecords.mixedCount")).toBe(CHART_SENTIMENT_COLORS.mixed); + }); + + test("returns undefined for non-sentiment measures", () => { + expect(getSentimentMeasureColor("FeedbackRecords.count")).toBeUndefined(); + expect(getSentimentMeasureColor("FeedbackRecords.joyCount")).toBeUndefined(); + expect(getSentimentMeasureColor("FeedbackRecords.sentimentAverage")).toBeUndefined(); + }); + }); + describe("pivotMeasuresToCategories", () => { const label = (key: string) => `label:${key}`; @@ -189,6 +252,19 @@ describe("chart-utils", () => { const result = pivotMeasuresToCategories([{}], keys, label); expect(result.at(-1)?.fill).toBe(CHART_MEASURE_COLORS[0]); }); + + test("sentiment count measures keep their semantic colors and don't consume palette hues", () => { + const result = pivotMeasuresToCategories( + [{}], + ["FeedbackRecords.veryPositiveCount", "FeedbackRecords.count", "FeedbackRecords.mixedCount"], + label + ); + expect(result.map((row) => row.fill)).toEqual([ + CHART_SENTIMENT_COLORS.very_positive, + CHART_MEASURE_COLORS[0], + CHART_SENTIMENT_COLORS.mixed, + ]); + }); }); describe("constants", () => { @@ -200,5 +276,13 @@ describe("chart-utils", () => { test("CHART_MEASURE_COLORS has no duplicate hues", () => { expect(new Set(CHART_MEASURE_COLORS).size).toBe(CHART_MEASURE_COLORS.length); }); + + test("CHART_SENTIMENT_COLORS covers every enum value with distinct colors", () => { + expect(Object.keys(CHART_SENTIMENT_COLORS).sort()).toEqual([...SENTIMENT_VALUE_ORDER].sort()); + const colors = Object.values(CHART_SENTIMENT_COLORS); + expect(new Set(colors).size).toBe(colors.length); + // the semantic scale must not collide with the "not enriched" gray + expect(colors).not.toContain(CHART_NOT_ENRICHED_COLOR); + }); }); }); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts index 7b03c607e223..781ee31f27a3 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts @@ -1,5 +1,11 @@ import { format, isValid, parseISO } from "date-fns"; -import { isNotEnrichedDimensionValue } from "@/modules/ee/analysis/lib/schema-definition"; +import { + SENTIMENT_DIMENSION_ID, + type TSentimentValue, + getSentimentValueForMeasureId, + isNotEnrichedDimensionValue, + isSentimentValue, +} from "@/modules/ee/analysis/lib/schema-definition"; import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis"; import { ZChartType } from "@/modules/ee/analysis/types/analysis"; @@ -24,9 +30,50 @@ export const CHART_MEASURE_COLORS = [ "#008300", // green ]; -/** Neutral gray for the "not enriched" bucket (empty sentiment/emotion values). Reads as muted in - * both light and dark so it doesn't compete with the categorical palette. */ -export const CHART_NOT_ENRICHED_COLOR = "#94a3b8"; // slate-400 +/** Neutral gray for the "not enriched" bucket (empty sentiment/emotion values). A true gray (no + * slate blue cast) that reads as muted in both light and dark so it doesn't compete with the + * categorical palette. */ +export const CHART_NOT_ENRICHED_COLOR = "#a3a3a3"; // neutral-400 + +/** + * Semantic scale for sentiment buckets, keyed by enum value (never by series index) so each bucket + * keeps its color regardless of which buckets appear. Hues deliberately mirror the emotion chart, + * where the six emotion count measures take CHART_MEASURE_COLORS in EMOTION_MEASURE_ORDER: very + * negative = sadness red, negative = disgust orange, neutral = surprise blue, mixed = anger + * yellow; positive is the brand teal and very positive the next-darker brand step + * (--color-brandnew in globals.css). Validated with the dataviz palette script on white: lightness + * band and adjacent-pair CVD separation pass (worst adjacent ΔE 16.2, deutan); the dark brand teal + * sits just under the categorical chroma floor, acceptable for a brand hue. Groundwork for the + * sentiment-only chart (ENG-1558). + */ +export const CHART_SENTIMENT_COLORS: Record = { + very_negative: "#e34948", // red (palette red — sadness) + negative: "#eb6834", // orange (palette orange — disgust) + neutral: "#2a78d6", // blue (palette blue — surprise) + positive: CHART_BRAND_DARK, // teal (brand) + very_positive: "#038178", // dark teal (brand, one step darker) + mixed: "#eda100", // yellow (palette yellow — anger) +}; + +/** + * Semantic color for an enum dimension value: gray for the "not enriched" bucket, the sentiment + * scale for sentiment values. Returns undefined when the value has no semantic color (other + * dimensions, unknown tokens) so callers fall back to the generic palette. + */ +export const getSemanticDimensionColor = (dimensionId: string, value: unknown): string | undefined => { + if (isNotEnrichedDimensionValue(dimensionId, value)) return CHART_NOT_ENRICHED_COLOR; + if (dimensionId === SENTIMENT_DIMENSION_ID && typeof value === "string" && isSentimentValue(value)) { + return CHART_SENTIMENT_COLORS[value]; + } + return undefined; +}; + +/** Semantic series color for the sentiment count measures (e.g. "FeedbackRecords.veryPositiveCount"), + * matching the dimension buckets. Undefined for every other measure. */ +export const getSentimentMeasureColor = (measureId: string): string | undefined => { + const value = getSentimentValueForMeasureId(measureId); + return value ? CHART_SENTIMENT_COLORS[value] : undefined; +}; /** Validate a chart type string, defaulting to "bar" if unrecognized. */ export const resolveChartType = (raw: string): TChartType => { @@ -57,13 +104,13 @@ export const preparePieData = ( .sort((a, b) => Number(b[dataKey]) - Number(a[dataKey])); if (processedData.length === 0) return null; - // The "not enriched" slice takes a neutral gray; palette colors are handed out only to the - // enriched slices so the gray bucket doesn't consume a categorical hue. + // Semantic slices (the gray "not enriched" bucket, the sentiment scale) keep their meaning-bound + // colors; palette colors are handed out only to the remaining slices so a semantic bucket doesn't + // consume a categorical hue. let paletteIndex = 0; const colors = processedData.map((row) => { - if (nameKey && isNotEnrichedDimensionValue(nameKey, row[nameKey])) { - return CHART_NOT_ENRICHED_COLOR; - } + const semanticColor = nameKey ? getSemanticDimensionColor(nameKey, row[nameKey]) : undefined; + if (semanticColor) return semanticColor; const color = CHART_MEASURE_COLORS[paletteIndex % CHART_MEASURE_COLORS.length]; paletteIndex++; return color; @@ -91,13 +138,21 @@ export function pivotMeasuresToCategories( formatLabel: (measureKey: string) => string ): TChartDataRow[] { const row = data[0] ?? {}; - return measureKeys.map((key, index) => { + // Like preparePieData: semantic buckets (sentiment counts) keep their meaning-bound colors and + // don't consume a categorical hue; the palette is handed out only to the remaining measures. + let paletteIndex = 0; + return measureKeys.map((key) => { const num = Number(row[key]); + let fill = getSentimentMeasureColor(key); + if (!fill) { + fill = CHART_MEASURE_COLORS[paletteIndex % CHART_MEASURE_COLORS.length]; + paletteIndex++; + } return { [PIVOTED_MEASURE_KEY]: key, [PIVOTED_VALUE_KEY]: Number.isFinite(num) ? num : 0, tooltipLabel: formatLabel(key), - fill: CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], + fill, }; }); } diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts index 076bd712ce27..88acac3f47d6 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts @@ -14,6 +14,7 @@ import { getFieldById, getFilterOperatorsForType, getMeasureAxisLabel, + getSentimentValueForMeasureId, getTranslatedDimensionValueLabel, getTranslatedFieldLabel, isEnrichmentDimensionId, @@ -219,6 +220,18 @@ describe("schema-definition", () => { expect([...SENTIMENT_MEASURE_ORDER].sort()).toEqual([...SENTIMENT_VALUE_ORDER].sort()); }); + test("maps each sentiment count measure id back to its enum value", () => { + expect(getSentimentValueForMeasureId("FeedbackRecords.veryNegativeCount")).toBe("very_negative"); + expect(getSentimentValueForMeasureId("FeedbackRecords.negativeCount")).toBe("negative"); + expect(getSentimentValueForMeasureId("FeedbackRecords.neutralCount")).toBe("neutral"); + expect(getSentimentValueForMeasureId("FeedbackRecords.positiveCount")).toBe("positive"); + expect(getSentimentValueForMeasureId("FeedbackRecords.veryPositiveCount")).toBe("very_positive"); + expect(getSentimentValueForMeasureId("FeedbackRecords.mixedCount")).toBe("mixed"); + // non-sentiment measures resolve to nothing + expect(getSentimentValueForMeasureId("FeedbackRecords.count")).toBeUndefined(); + expect(getSentimentValueForMeasureId("FeedbackRecords.joyCount")).toBeUndefined(); + }); + test("labels each sentiment count measure (no raw-id fallback)", () => { const t = ((key: string) => key) as TFunction; const sentimentCountIds = [ diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.ts b/apps/web/modules/ee/analysis/lib/schema-definition.ts index 823223f1830f..6343ac95047e 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.ts @@ -384,9 +384,15 @@ export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions export const SENTIMENT_DIMENSION_ID = "FeedbackRecords.sentiment"; export const EMOTIONS_DIMENSION_ID = "FeedbackRecords.emotions"; -const isSentimentValue = (value: string): value is TSentimentValue => +export const isSentimentValue = (value: string): value is TSentimentValue => (SENTIMENT_VALUE_ORDER as readonly string[]).includes(value); +/** Map a sentiment count measure id (e.g. "FeedbackRecords.veryPositiveCount") back to its enum + * value. Charts use this to give the sentiment count series the same semantic colors as the + * sentiment dimension buckets. Returns undefined for every other measure. */ +export const getSentimentValueForMeasureId = (measureId: string): TSentimentValue | undefined => + SENTIMENT_VALUE_ORDER.find((value) => measureId === `FeedbackRecords.${toCountMeasureId(value)}Count`); + const isEmotionValue = (value: string): value is TEmotionValue => (EMOTION_VALUES as readonly string[]).includes(value); diff --git a/apps/web/modules/ee/unify-feedback/sources/components/csv-import-modal.tsx b/apps/web/modules/ee/unify-feedback/sources/components/csv-import-modal.tsx index 45898209d966..7555b52953cd 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/csv-import-modal.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/csv-import-modal.tsx @@ -73,12 +73,12 @@ export function CsvImportModal({ const missingMappedColumns = getMissingCsvMappedSourceColumns( fieldMappings, - Object.keys(result.data[0] ?? {}) + Object.keys(result.data[0]) ); if (missingMappedColumns.length > 0) { const missingRequiredSourceColumns = getMissingRequiredCsvSourceColumns( fieldMappings, - Object.keys(result.data[0] ?? {}) + Object.keys(result.data[0]) ); const missingSourceColumns = missingRequiredSourceColumns.length > 0 diff --git a/apps/web/modules/ee/unify-feedback/sources/csv-empty-guard.test.ts b/apps/web/modules/ee/unify-feedback/sources/csv-empty-guard.test.ts new file mode 100644 index 000000000000..932547e163af --- /dev/null +++ b/apps/web/modules/ee/unify-feedback/sources/csv-empty-guard.test.ts @@ -0,0 +1,45 @@ +import { parse } from "csv-parse/sync"; +import type { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import { createFeedbackCSVDataSchema } from "./types"; + +// ENG-1799: a header-only CSV (zero data rows) must surface the friendly CSV_AT_LEAST_ONE_ROW error, +// NOT crash with a raw "Cannot convert undefined to object". The crash was inside the schema itself: +// `createFeedbackCSVDataSchema`'s `.superRefine` dereferenced `Object.keys(rows[0])`, and zod runs the +// refinement even when the preceding `.min(1)` fails — so an empty array threw a raw TypeError out of +// `safeParse` (which is why callers saw it as a thrown error, not a validation failure). This drives the +// real schema + csv-parse through the exact parse+validate chain both consumers use +// (csv-feedback-source-ui.tsx, csv-import-modal.tsx) to prove empty input now fails validation cleanly. + +const t = ((key: string) => key) as unknown as TFunction; + +// Mirror the component's parse options exactly (csv-feedback-source-ui.tsx:136). +const parseCsv = (csv: string) => + parse(csv, { columns: true, relax_column_count: true, skip_empty_lines: true }) as Record[]; + +describe("createFeedbackCSVDataSchema — header-only CSV guard (ENG-1799)", () => { + test.each([ + ["header + trailing newline", "submission_id,field_id,field_type,response_value\n"], + ["header, no trailing newline", "submission_id,field_id,field_type,response_value"], + ["header + blank lines", "submission_id,field_id,field_type,response_value\n\n\n"], + ])("%s → zero rows, validation fails cleanly instead of throwing", (_label, csv) => { + const records = parseCsv(csv); + + // The precondition that made the refinement throw: rows[0] is undefined. + expect(records).toHaveLength(0); + expect(records[0]).toBeUndefined(); + + // safeParse must resolve to a failure (not throw) with the friendly message. + const result = createFeedbackCSVDataSchema(t).safeParse(records); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("workspace.unify.csv_at_least_one_row"); + } + }); + + test("a valid single-row CSV still passes (guard does not over-reject)", () => { + const records = parseCsv("submission_id,field_id,field_type,response_value\nsub-1,q1,text,hi"); + expect(records).toHaveLength(1); + expect(createFeedbackCSVDataSchema(t).safeParse(records).success).toBe(true); + }); +}); diff --git a/apps/web/modules/ee/unify-feedback/sources/types.ts b/apps/web/modules/ee/unify-feedback/sources/types.ts index cdd71ea60bca..051e6ce562a4 100644 --- a/apps/web/modules/ee/unify-feedback/sources/types.ts +++ b/apps/web/modules/ee/unify-feedback/sources/types.ts @@ -249,8 +249,15 @@ export const createFeedbackCSVDataSchema = (t: TFunction) => }), }) .superRefine((rows, ctx) => { + // `.min(1)` above already reports empty input, but zod still runs this refinement, so guard + // against `rows[0]` being undefined — otherwise `Object.keys(undefined)` throws a raw TypeError + // out of safeParse instead of surfacing the friendly CSV_AT_LEAST_ONE_ROW error (ENG-1799). + if (rows.length === 0) { + return; + } const localeSort = (a: string, b: string) => a.localeCompare(b); - const firstRowKeys = Object.keys(rows[0]).sort(localeSort).join(","); + const firstRowKeyList = Object.keys(rows[0]); + const firstRowKeys = [...firstRowKeyList].sort(localeSort).join(","); for (let i = 1; i < rows.length; i++) { const rowKeys = Object.keys(rows[i]).sort(localeSort).join(","); @@ -263,7 +270,7 @@ export const createFeedbackCSVDataSchema = (t: TFunction) => } } - const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === ""); + const emptyHeaders = firstRowKeyList.filter((k) => k.trim() === ""); if (emptyHeaders.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/charts/formbricks/README.md b/charts/formbricks/README.md index 82e03b5ec797..3c3ed9710169 100644 --- a/charts/formbricks/README.md +++ b/charts/formbricks/README.md @@ -69,7 +69,9 @@ The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is When the Formbricks migration job is enabled, Hub waits for the `formbricks-migration` Job to complete before its own goose/river init migrations run. This keeps fresh shared-database installs from creating Hub tables before Prisma has initialized the Formbricks schema. If the Job has already been cleaned up, Hub only continues after all expected Prisma and data migration success markers are present in the database. -When deployed with Argo CD, chart-managed Secrets and ExternalSecrets render in sync wave `-2`, and the Formbricks and Hub migration hooks run in sync wave `-1`. This lets app and Hub secrets exist before migration jobs start. +Before migrations start, the migration Job waits for the effective PostgreSQL endpoint to accept TCP connections. `MIGRATE_DATABASE_URL` takes precedence over `DATABASE_URL`, matching the migration runner. Configure the timeout and retry interval under `migration.waitForDatabase`, or disable the readiness check for deployments that provide their own gate. + +When deployed with Argo CD, chart-managed Secrets, ExternalSecrets, and bundled PostgreSQL render in sync wave `-2`, and the Formbricks and Hub migration hooks run in sync wave `-1`. This lets app and Hub secrets exist and PostgreSQL become healthy before migration jobs start. Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub: @@ -433,6 +435,10 @@ JSON that can call Vertex AI. | migration.resources.requests.cpu | string | `"100m"` | | | migration.resources.requests.memory | string | `"256Mi"` | | | migration.ttlSecondsAfterFinished | int | `300` | | +| migration.waitForDatabase.connectionTimeoutSeconds | int | `5` | Per-attempt TCP connection timeout. | +| migration.waitForDatabase.enabled | bool | `true` | Wait for PostgreSQL before starting migrations. | +| migration.waitForDatabase.intervalSeconds | int | `5` | Delay between readiness attempts. | +| migration.waitForDatabase.timeoutSeconds | int | `900` | Overall readiness timeout for each Job attempt. | | nameOverride | string | `""` | | | partOfOverride | string | `""` | | | pdb.additionalLabels | object | `{}` | | @@ -444,6 +450,7 @@ JSON that can call Vertex AI. | postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | | | postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | | | postgresql.auth.username | string | `"formbricks"` | | +| postgresql.commonAnnotations | object | `{"argocd.argoproj.io/sync-wave":"-2"}` | Order bundled PostgreSQL before migration hooks in Argo. | | postgresql.enabled | bool | `true` | | | postgresql.externalDatabaseUrl | string | `""` | | | postgresql.fullnameOverride | string | `"formbricks-postgresql"` | | diff --git a/charts/formbricks/templates/_helpers.tpl b/charts/formbricks/templates/_helpers.tpl index 05e747a950ba..74025841fe82 100644 --- a/charts/formbricks/templates/_helpers.tpl +++ b/charts/formbricks/templates/_helpers.tpl @@ -168,6 +168,48 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to ` {{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}} {{- end }} +{{/* +Render the database environment shared by the migration readiness and migration containers. +Keeping this in one helper ensures both containers resolve DATABASE_URL and MIGRATE_DATABASE_URL identically. +*/}} +{{- define "formbricks.migrationEnvironment" -}} +{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }} +envFrom: +{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }} + - secretRef: + name: {{ template "formbricks.name" . }}-app-secrets +{{- end }} +{{- range $value := .Values.deployment.envFrom }} +{{- if (eq .type "configmap") }} + - configMapRef: + {{- if .name }} + name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }} + {{- else if .nameSuffix }} + name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }} + {{- else }} + name: {{ template "formbricks.name" $ }} + {{- end }} +{{- end }} +{{- if (eq .type "secret") }} + - secretRef: + {{- if .name }} + name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }} + {{- else if .nameSuffix }} + name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }} + {{- else }} + name: {{ template "formbricks.name" $ }} + {{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- if .Values.deployment.env }} +env: +{{- range $key, $value := .Values.deployment.env }} + {{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 2 }} +{{- end }} +{{- end }} +{{- end }} + {{/* Formbricks application image reference. A configured digest takes precedence over the tag. */}} diff --git a/charts/formbricks/templates/migration-job.yaml b/charts/formbricks/templates/migration-job.yaml index 970d6b5f3482..0910c2eda38c 100644 --- a/charts/formbricks/templates/migration-job.yaml +++ b/charts/formbricks/templates/migration-job.yaml @@ -45,6 +45,27 @@ spec: securityContext: {{- toYaml .Values.deployment.securityContext | nindent 8 }} {{- end }} + {{- if .Values.migration.waitForDatabase.enabled }} + initContainers: + - name: wait-for-database + image: {{ include "formbricks.deploymentImage" . }} + imagePullPolicy: {{ .Values.deployment.image.pullPolicy }} + command: + - node + - packages/database/dist/scripts/wait-for-database.js + args: + - --timeout-seconds + - {{ .Values.migration.waitForDatabase.timeoutSeconds | quote }} + - --interval-seconds + - {{ .Values.migration.waitForDatabase.intervalSeconds | quote }} + - --connection-timeout-seconds + - {{ .Values.migration.waitForDatabase.connectionTimeoutSeconds | quote }} + {{- include "formbricks.migrationEnvironment" . | nindent 10 }} + {{- if .Values.migration.resources }} + resources: + {{- toYaml .Values.migration.resources | nindent 12 }} + {{- end }} + {{- end }} containers: - name: migration image: {{ include "formbricks.deploymentImage" . }} @@ -52,39 +73,7 @@ spec: command: - node - packages/database/dist/scripts/apply-migrations.js - {{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }} - envFrom: - {{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }} - - secretRef: - name: {{ template "formbricks.name" . }}-app-secrets - {{- end }} - {{- range $value := .Values.deployment.envFrom }} - {{- if (eq .type "configmap") }} - - configMapRef: - {{- if .name }} - name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }} - {{- else if .nameSuffix }} - name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }} - {{- else }} - name: {{ template "formbricks.name" $ }} - {{- end }} - {{- end }} - {{- if (eq .type "secret") }} - - secretRef: - {{- if .name }} - name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }} - {{- else if .nameSuffix }} - name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }} - {{- else }} - name: {{ template "formbricks.name" $ }} - {{- end }} - {{- end }} - {{- end }} - {{- end }} - env: - {{- range $key, $value := .Values.deployment.env }} - {{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }} - {{- end }} + {{- include "formbricks.migrationEnvironment" . | nindent 10 }} {{- if .Values.migration.resources }} resources: {{- toYaml .Values.migration.resources | nindent 12 }} diff --git a/charts/formbricks/values.yaml b/charts/formbricks/values.yaml index 66d557f20588..57c03680c867 100644 --- a/charts/formbricks/values.yaml +++ b/charts/formbricks/values.yaml @@ -32,9 +32,9 @@ enterprise: # Database Migration Job Configuration Helm ########################################################## migration: - # Enable migration job for ArgoCD deployments - # When enabled, migrations run as a PreSync hook before the deployment - # and the startup migration in the container is skipped + # Enable the migration job. In Argo CD, it runs as a Sync hook in wave -1 + # after chart-managed secrets and bundled PostgreSQL are healthy. When enabled, + # the startup migration in the application container is skipped. enabled: true # Additional annotations for the migration job @@ -46,6 +46,14 @@ migration: # Number of retries before marking the job as failed backoffLimit: 3 + # Wait for the effective migration database endpoint before starting migrations. + # MIGRATE_DATABASE_URL takes precedence over DATABASE_URL, matching the migration runner. + waitForDatabase: + enabled: true + timeoutSeconds: 900 + intervalSeconds: 5 + connectionTimeoutSeconds: 5 + # Resource requests and limits for the migration job resources: limits: @@ -1238,6 +1246,9 @@ hub: postgresql: enabled: true # Enable/disable PostgreSQL externalDatabaseUrl: "" + # Argo CD waits for bundled PostgreSQL before running migration hooks in wave -1. + commonAnnotations: + argocd.argoproj.io/sync-wave: "-2" global: security: allowInsecureImages: true diff --git a/packages/database/package.json b/packages/database/package.json index d5308f26017f..757658066625 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -59,6 +59,7 @@ "generate": "node ./src/scripts/clean-generated-prisma.mjs && prisma generate", "lint": "eslint ./src --fix", "generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts", + "test": "vitest run", "typecheck": "tsc --noEmit", "create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts" }, diff --git a/packages/database/src/scripts/wait-for-database.test.ts b/packages/database/src/scripts/wait-for-database.test.ts new file mode 100644 index 000000000000..bb4df4bdd5cc --- /dev/null +++ b/packages/database/src/scripts/wait-for-database.test.ts @@ -0,0 +1,216 @@ +import { once } from "node:events"; +import * as NodeNet from "node:net"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + checkDatabaseTcpConnection, + getMigrationDatabaseUrl, + parseCliOptions, + parseDatabaseEndpoint, + waitForDatabase, +} from "./wait-for-database"; + +const { createConnectionMock } = vi.hoisted(() => ({ + createConnectionMock: vi.fn(), +})); + +vi.mock("node:net", async (importOriginal) => { + const actual = await importOriginal(); + createConnectionMock.mockImplementation(actual.createConnection); + + return { + ...actual, + createConnection: createConnectionMock, + }; +}); + +const servers: ReturnType[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }) + ) + ); +}); + +describe("getMigrationDatabaseUrl", () => { + test("prefers a non-empty MIGRATE_DATABASE_URL", () => { + expect( + getMigrationDatabaseUrl({ + DATABASE_URL: "postgresql://app:secret@app-db/formbricks", + MIGRATE_DATABASE_URL: " postgresql://migrate:secret@migrate-db/formbricks ", + }) + ).toBe("postgresql://migrate:secret@migrate-db/formbricks"); + }); + + test("falls back to DATABASE_URL", () => { + expect( + getMigrationDatabaseUrl({ + DATABASE_URL: "postgresql://app:secret@app-db/formbricks", + MIGRATE_DATABASE_URL: " ", + }) + ).toBe("postgresql://app:secret@app-db/formbricks"); + }); + + test("fails when neither URL is configured", () => { + expect(() => getMigrationDatabaseUrl({})).toThrow("MIGRATE_DATABASE_URL or DATABASE_URL is required"); + }); +}); + +describe("parseDatabaseEndpoint", () => { + test.each([ + ["postgresql://user:secret@database/formbricks", { host: "database", port: 5432 }], + ["postgres://user:secret@database:6432/formbricks", { host: "database", port: 6432 }], + ["postgresql://user:secret@[2001:db8::1]:7432/formbricks", { host: "2001:db8::1", port: 7432 }], + ])("parses %s", (databaseUrl, expectedEndpoint) => { + expect(parseDatabaseEndpoint(databaseUrl)).toEqual(expectedEndpoint); + }); + + test("rejects non-PostgreSQL URLs without echoing the value", () => { + const secretUrl = "mysql://admin:super-secret@database/formbricks"; + + try { + parseDatabaseEndpoint(secretUrl); + expect.unreachable("Expected URL parsing to fail"); + } catch (error) { + expect(String(error)).toContain("must use the postgres or postgresql protocol"); + expect(String(error)).not.toContain("admin"); + expect(String(error)).not.toContain("super-secret"); + } + }); +}); + +describe("parseCliOptions", () => { + test("uses safe defaults", () => { + expect(parseCliOptions([])).toEqual({ + connectionTimeoutSeconds: 5, + intervalSeconds: 5, + timeoutSeconds: 900, + }); + }); + + test("accepts positive overrides", () => { + expect( + parseCliOptions([ + "--timeout-seconds", + "60", + "--interval-seconds", + "2", + "--connection-timeout-seconds", + "3", + ]) + ).toEqual({ connectionTimeoutSeconds: 3, intervalSeconds: 2, timeoutSeconds: 60 }); + }); +}); + +describe("waitForDatabase", () => { + test("retries delayed availability without failing the caller", async () => { + let currentTimeMs = 0; + const checkConnection = vi + .fn<() => Promise>() + .mockRejectedValueOnce(Object.assign(new Error("refused"), { code: "ECONNREFUSED" })) + .mockRejectedValueOnce(Object.assign(new Error("refused"), { code: "ECONNREFUSED" })) + .mockResolvedValue(undefined); + + await waitForDatabase( + { + connectionTimeoutSeconds: 1, + endpoint: { host: "database", port: 5432 }, + intervalSeconds: 2, + timeoutSeconds: 10, + }, + { + checkConnection, + log: vi.fn(), + now: () => currentTimeMs, + sleep: (durationMs) => { + currentTimeMs += durationMs; + return Promise.resolve(); + }, + } + ); + + expect(checkConnection).toHaveBeenCalledTimes(3); + }); + + test("times out with a safe error code", async () => { + let currentTimeMs = 0; + const logs: string[] = []; + + await expect( + waitForDatabase( + { + connectionTimeoutSeconds: 1, + endpoint: { host: "database", port: 5432 }, + intervalSeconds: 5, + timeoutSeconds: 10, + }, + { + checkConnection: () => + Promise.reject( + Object.assign(new Error("postgresql://admin:super-secret@database/formbricks"), { + code: "ECONNREFUSED", + }) + ), + log: (message) => logs.push(message), + now: () => currentTimeMs, + sleep: (durationMs) => { + currentTimeMs += durationMs; + return Promise.resolve(); + }, + } + ) + ).rejects.toThrow("last error code: ECONNREFUSED"); + + expect(logs.join("\n")).not.toContain("admin"); + expect(logs.join("\n")).not.toContain("super-secret"); + }); +}); + +describe("checkDatabaseTcpConnection", () => { + test("closes the socket after a successful readiness check", async () => { + const serverSockets = new Set(); + const server = NodeNet.createServer((socket) => { + serverSockets.add(socket); + socket.once("close", () => { + serverSockets.delete(socket); + }); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address() as NodeNet.AddressInfo; + + await checkDatabaseTcpConnection({ host: "127.0.0.1", port: address.port }, 1_000); + + await vi.waitFor(() => { + expect(serverSockets.size).toBe(0); + }); + }); + + test("handles errors emitted after timeout cleanup", async () => { + const socket = new NodeNet.Socket(); + const lateError = Object.assign(new Error("connection cancelled"), { code: "ECANCELED" }); + const destroySpy = vi.spyOn(socket, "destroy").mockImplementation(() => { + queueMicrotask(() => { + socket.emit("error", lateError); + }); + return socket; + }); + createConnectionMock.mockReturnValueOnce(socket); + + const connection = checkDatabaseTcpConnection({ host: "database", port: 5432 }, 1_000); + socket.emit("timeout"); + + await expect(connection).rejects.toMatchObject({ code: "ETIMEDOUT" }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(destroySpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/database/src/scripts/wait-for-database.ts b/packages/database/src/scripts/wait-for-database.ts new file mode 100644 index 000000000000..1b0632079f75 --- /dev/null +++ b/packages/database/src/scripts/wait-for-database.ts @@ -0,0 +1,242 @@ +import { createConnection } from "node:net"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_TIMEOUT_SECONDS = 900; +const DEFAULT_INTERVAL_SECONDS = 5; +const DEFAULT_CONNECTION_TIMEOUT_SECONDS = 5; + +export interface TDatabaseEndpoint { + host: string; + port: number; +} + +interface TWaitForDatabaseOptions { + connectionTimeoutSeconds: number; + endpoint: TDatabaseEndpoint; + intervalSeconds: number; + timeoutSeconds: number; +} + +interface TWaitForDatabaseDependencies { + checkConnection?: (endpoint: TDatabaseEndpoint, timeoutMs: number) => Promise; + log?: (message: string) => void; + now?: () => number; + sleep?: (durationMs: number) => Promise; +} + +interface TWaitForDatabaseCliOptions { + connectionTimeoutSeconds: number; + intervalSeconds: number; + timeoutSeconds: number; +} + +const sleep = async (durationMs: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +}; + +const toPositiveNumber = (value: string, optionName: string): number => { + const parsedValue = Number(value); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + throw new Error(`${optionName} must be a positive number.`); + } + + return parsedValue; +}; + +const getSafeErrorCode = (error: unknown): string => { + if (typeof error === "object" && error !== null && "code" in error) { + const code = String(error.code); + return code.length > 0 ? code : "UNKNOWN"; + } + + return error instanceof Error && error.name.length > 0 ? error.name : "UNKNOWN"; +}; + +export const getMigrationDatabaseUrl = ( + environment: { DATABASE_URL?: string; MIGRATE_DATABASE_URL?: string } = process.env +): string => { + const migrateDatabaseUrl = environment.MIGRATE_DATABASE_URL?.trim(); + if (migrateDatabaseUrl) { + return migrateDatabaseUrl; + } + + const databaseUrl = environment.DATABASE_URL?.trim(); + if (databaseUrl) { + return databaseUrl; + } + + throw new Error("MIGRATE_DATABASE_URL or DATABASE_URL is required to wait for PostgreSQL."); +}; + +export const parseDatabaseEndpoint = (databaseUrl: string): TDatabaseEndpoint => { + let parsedUrl: URL; + + try { + parsedUrl = new URL(databaseUrl); + } catch { + throw new Error("The migration database URL must be a valid PostgreSQL URL."); + } + + if (parsedUrl.protocol !== "postgres:" && parsedUrl.protocol !== "postgresql:") { + throw new Error("The migration database URL must use the postgres or postgresql protocol."); + } + + if (!parsedUrl.hostname) { + throw new Error("The migration database URL must include a hostname."); + } + + const port = parsedUrl.port ? Number(parsedUrl.port) : 5432; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("The migration database URL must include a valid TCP port."); + } + + const host = parsedUrl.hostname.startsWith("[") ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; + + return { host, port }; +}; + +export const checkDatabaseTcpConnection = async ( + endpoint: TDatabaseEndpoint, + timeoutMs: number +): Promise => { + await new Promise((resolve, reject) => { + const socket = createConnection({ host: endpoint.host, port: endpoint.port }); + let settled = false; + + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + socket.destroy(); + + if (error) { + reject(error); + return; + } + + resolve(); + }; + + socket.setTimeout(timeoutMs); + socket.once("connect", () => { + finish(); + }); + socket.on("error", (error) => { + finish(error); + }); + socket.once("timeout", () => { + const timeoutError = Object.assign(new Error("PostgreSQL TCP connection timed out."), { + code: "ETIMEDOUT", + }); + finish(timeoutError); + }); + }); +}; + +export const waitForDatabase = async ( + options: TWaitForDatabaseOptions, + dependencies: TWaitForDatabaseDependencies = {} +): Promise => { + const checkConnection = dependencies.checkConnection ?? checkDatabaseTcpConnection; + const log = dependencies.log ?? console.log; + const now = dependencies.now ?? Date.now; + const wait = dependencies.sleep ?? sleep; + const timeoutMs = options.timeoutSeconds * 1000; + const intervalMs = options.intervalSeconds * 1000; + const connectionTimeoutMs = options.connectionTimeoutSeconds * 1000; + const startedAt = now(); + let attempt = 0; + let lastErrorCode = "UNKNOWN"; + + while (now() - startedAt < timeoutMs) { + const remainingMs = timeoutMs - (now() - startedAt); + + if (remainingMs <= 0) { + throw new Error( + `PostgreSQL did not become reachable within ${options.timeoutSeconds.toString()} seconds (last error code: ${lastErrorCode}).` + ); + } + + attempt += 1; + + try { + await checkConnection(options.endpoint, Math.min(connectionTimeoutMs, remainingMs)); + log(`PostgreSQL is reachable after ${attempt.toString()} attempt(s).`); + return; + } catch (error) { + lastErrorCode = getSafeErrorCode(error); + const remainingAfterAttemptMs = timeoutMs - (now() - startedAt); + + if (remainingAfterAttemptMs <= 0) { + throw new Error( + `PostgreSQL did not become reachable within ${options.timeoutSeconds.toString()} seconds (last error code: ${lastErrorCode}).` + ); + } + + const delayMs = Math.min(intervalMs, remainingAfterAttemptMs); + log( + `PostgreSQL is not reachable yet (attempt ${attempt.toString()}, error code: ${lastErrorCode}); retrying in ${(delayMs / 1000).toString()} second(s).` + ); + await wait(delayMs); + } + } + + throw new Error( + `PostgreSQL did not become reachable within ${options.timeoutSeconds.toString()} seconds (last error code: ${lastErrorCode}).` + ); +}; + +export const parseCliOptions = (args: string[]): TWaitForDatabaseCliOptions => { + const options: TWaitForDatabaseCliOptions = { + connectionTimeoutSeconds: DEFAULT_CONNECTION_TIMEOUT_SECONDS, + intervalSeconds: DEFAULT_INTERVAL_SECONDS, + timeoutSeconds: DEFAULT_TIMEOUT_SECONDS, + }; + + for (let index = 0; index < args.length; index += 1) { + const optionName = args[index]; + const optionValue = args[index + 1]; + + if (!optionValue) { + throw new Error(`${optionName} requires a value.`); + } + + switch (optionName) { + case "--connection-timeout-seconds": + options.connectionTimeoutSeconds = toPositiveNumber(optionValue, optionName); + break; + case "--interval-seconds": + options.intervalSeconds = toPositiveNumber(optionValue, optionName); + break; + case "--timeout-seconds": + options.timeoutSeconds = toPositiveNumber(optionValue, optionName); + break; + default: + throw new Error(`Unknown option: ${optionName}`); + } + + index += 1; + } + + return options; +}; + +const main = async (): Promise => { + const databaseUrl = getMigrationDatabaseUrl(); + const endpoint = parseDatabaseEndpoint(databaseUrl); + const options = parseCliOptions(process.argv.slice(2)); + + await waitForDatabase({ endpoint, ...options }); +}; + +const isDirectExecution = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false; + +if (isDirectExecution) { + main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "PostgreSQL readiness check failed."; + console.error(message); + process.exit(1); + }); +} diff --git a/packages/database/vite.config.ts b/packages/database/vite.config.ts index 406fbc6ca5dc..12146249c3f1 100644 --- a/packages/database/vite.config.ts +++ b/packages/database/vite.config.ts @@ -48,6 +48,7 @@ export default defineConfig(async (): Promise => { "scripts/apply-migrations": resolve(__dirname, "src/scripts/apply-migrations.ts"), "scripts/create-saml-database": resolve(__dirname, "src/scripts/create-saml-database.ts"), "scripts/migration-runner": resolve(__dirname, "src/scripts/migration-runner.ts"), + "scripts/wait-for-database": resolve(__dirname, "src/scripts/wait-for-database.ts"), "scripts/backfill-attribute-values": resolve(__dirname, "src/scripts/backfill-attribute-values.ts"), ...migrationEntries, },