diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx index 64bf21132d5f..9bfe58cf0b9b 100644 --- a/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx +++ b/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx @@ -5,7 +5,7 @@ import { CheckIcon, TriangleAlertIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/cn"; -import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; +import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/hooks/use-trigger-survey-picker"; import { deriveTriggerEndingProblems, workflowAtom, diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx index a04823ce73ec..2917079abeb1 100644 --- a/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx @@ -10,12 +10,12 @@ import { WorkflowFieldError, WorkflowFieldLabel, } from "@/modules/ee/workflows/components/inspector/workflow-field"; -import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus"; -import { reconcileEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards"; import { useWorkflowSurveyEndings, useWorkflowSurveyOptions, -} from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; +} from "@/modules/ee/workflows/hooks/use-trigger-survey-picker"; +import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus"; +import { reconcileEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards"; import { hasBoundTriggerSurveyAtom, prunedTriggerEndingCardIdsAtom, diff --git a/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts b/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts index 8b3f5888e568..271da10df779 100644 --- a/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts +++ b/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts @@ -3,8 +3,8 @@ import { useSetAtom } from "jotai"; import { useEffect } from "react"; import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/hooks/use-trigger-survey-picker"; import { reconcileDefinitionEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards"; -import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; import { prunedTriggerEndingCardIdsAtom, setWorkflowDefinitionAtom, diff --git a/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.test.ts b/apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.test.ts similarity index 100% rename from apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.test.ts rename to apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.test.ts diff --git a/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.ts b/apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.ts similarity index 100% rename from apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.ts rename to apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.ts diff --git a/apps/web/modules/ee/workflows/list/hooks/use-workflows.ts b/apps/web/modules/ee/workflows/list/hooks/use-workflows.ts deleted file mode 100644 index 38a0ed15f84d..000000000000 --- a/apps/web/modules/ee/workflows/list/hooks/use-workflows.ts +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; -import { flattenWorkflowPages, workflowKeys } from "@/modules/ee/workflows/list/lib/query"; -import { listWorkflows } from "@/modules/ee/workflows/list/lib/v3-workflows-client"; - -interface UseWorkflowsArgs { - workspaceId: string; - limit: number; - enabled?: boolean; -} - -export const useWorkflows = ({ workspaceId, limit, enabled = true }: UseWorkflowsArgs) => { - const queryKey = workflowKeys.list({ workspaceId, limit }); - - const query = useInfiniteQuery({ - queryKey, - enabled, - initialPageParam: null as string | null, - placeholderData: keepPreviousData, - queryFn: ({ pageParam, signal }) => listWorkflows({ workspaceId, limit, cursor: pageParam, signal }), - getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined, - }); - - return { - ...query, - queryKey, - workflows: flattenWorkflowPages(query.data), - }; -}; diff --git a/apps/web/modules/ee/workflows/list/lib/query.ts b/apps/web/modules/ee/workflows/list/lib/query.ts deleted file mode 100644 index 1e28ffa4385e..000000000000 --- a/apps/web/modules/ee/workflows/list/lib/query.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { InfiniteData } from "@tanstack/react-query"; -import type { TWorkflowListItem } from "@formbricks/workflows"; -import type { TWorkflowListPage } from "./v3-workflows-client"; - -interface TWorkflowListKeyInput { - workspaceId: string; - limit: number; -} - -export const workflowKeys = { - all: ["workflows"] as const, - lists: () => [...workflowKeys.all, "list"] as const, - list: (input: TWorkflowListKeyInput) => [...workflowKeys.lists(), input] as const, -}; - -export function flattenWorkflowPages(data?: InfiniteData): TWorkflowListItem[] { - return data?.pages.flatMap((page) => page.data) ?? []; -} diff --git a/apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts b/apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts deleted file mode 100644 index b2d102e48bc2..000000000000 --- a/apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { TWorkflowListItem } from "@formbricks/workflows"; -import { parseV3ApiError } from "@/modules/api/lib/v3-client"; - -export interface TWorkflowListPage { - data: TWorkflowListItem[]; - meta: { limit: number; nextCursor: string | null }; -} - -interface TV3WorkflowListResponse { - data: TWorkflowListItem[]; - meta: TWorkflowListPage["meta"]; -} - -interface BuildSearchParamsArgs { - workspaceId: string; - limit: number; - cursor?: string | null; -} - -export function buildWorkflowListSearchParams({ - workspaceId, - limit, - cursor, -}: BuildSearchParamsArgs): URLSearchParams { - const params = new URLSearchParams(); - params.set("workspaceId", workspaceId); - params.set("limit", String(limit)); - if (cursor) params.set("cursor", cursor); - return params; -} - -interface ListWorkflowsArgs extends BuildSearchParamsArgs { - signal?: AbortSignal; -} - -export async function listWorkflows({ - workspaceId, - limit, - cursor, - signal, -}: ListWorkflowsArgs): Promise { - const response = await fetch( - `/api/v3/workflows?${buildWorkflowListSearchParams({ workspaceId, limit, cursor }).toString()}`, - { method: "GET", cache: "no-store", signal } - ); - - if (!response.ok) { - throw await parseV3ApiError(response); - } - - const body = (await response.json()) as TV3WorkflowListResponse; - return { data: body.data, meta: body.meta }; -} diff --git a/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx b/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx index d2a8db9ef966..ee84ad4705ff 100644 --- a/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx +++ b/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx @@ -7,10 +7,10 @@ import { WorkflowCanvas } from "@/modules/ee/workflows/components/canvas/workflo import { WorkflowInspectorPanel } from "@/modules/ee/workflows/components/inspector/workflow-inspector-panel"; import { WorkflowEmailAuthoringProvider } from "@/modules/ee/workflows/components/workflow-email-authoring-context"; import { useReconcileTriggerEndingCards } from "@/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards"; +import { useWorkflowSurveyOptions } from "@/modules/ee/workflows/hooks/use-trigger-survey-picker"; import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder"; import { useWorkflowNodeUrlSync } from "@/modules/ee/workflows/hooks/use-workflow-node-url-sync"; import { resolveBoundTriggerSurvey } from "@/modules/ee/workflows/lib/bound-survey"; -import { useWorkflowSurveyOptions } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; import { WorkflowBuilderBodyLoading } from "@/modules/ee/workflows/loading"; import { hasBoundTriggerSurveyAtom } from "@/modules/ee/workflows/state/editor"; import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context"; diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index 8039581ed3ef..cd72e88d4c42 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -332,6 +332,13 @@ export const sendResponseFinishedEmail = async ( return element; }); + // The whitelabel logo is stored as a relative `/storage/...` path, which an email client cannot + // resolve — it has no origin to resolve against, so the `` renders broken. Resolve it to an + // absolute URL here, exactly like every other email sender does. + const logoUrl = organization.whitelabel?.logoUrl + ? resolveStorageUrl(organization.whitelabel.logoUrl) + : undefined; + const html = await renderResponseFinishedEmail({ survey, responseCount, @@ -340,6 +347,7 @@ export const sendResponseFinishedEmail = async ( workspaceId, organization, elements: elementsWithResolvedUrls, + logoUrl, t, ...legalProps, }); diff --git a/apps/web/modules/email/response-finished-email.test.ts b/apps/web/modules/email/response-finished-email.test.ts new file mode 100644 index 000000000000..e9ea56b0a653 --- /dev/null +++ b/apps/web/modules/email/response-finished-email.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TResponse } from "@formbricks/types/responses"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { sendResponseFinishedEmail } from "./index"; + +const { + mockRenderResponseFinishedEmail, + mockGetOrganizationByWorkspaceId, + mockGetElementResponseMapping, + mockResolveStorageUrl, + mockGetTranslate, + mockCreateTransport, +} = vi.hoisted(() => ({ + mockRenderResponseFinishedEmail: vi.fn(), + mockGetOrganizationByWorkspaceId: vi.fn(), + mockGetElementResponseMapping: vi.fn(), + mockResolveStorageUrl: vi.fn(), + mockGetTranslate: vi.fn(), + mockCreateTransport: vi.fn(() => ({ sendMail: vi.fn() })), +})); + +vi.mock("@formbricks/email", () => ({ + renderResponseFinishedEmail: mockRenderResponseFinishedEmail, +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByWorkspaceId: mockGetOrganizationByWorkspaceId, +})); + +vi.mock("@/lib/responses", () => ({ + getElementResponseMapping: mockGetElementResponseMapping, +})); + +vi.mock("@/modules/storage/utils", () => ({ + resolveStorageUrl: mockResolveStorageUrl, +})); + +vi.mock("@/lingodotdev/server", () => ({ + getTranslate: mockGetTranslate, +})); + +// The real transport would try to reach the SMTP host from .env (localhost:1025, nothing listening). +// Stub it so `sendEmail`'s side effect stays a no-op; the assertions only care about the render call. +vi.mock("nodemailer", () => ({ + createTransport: mockCreateTransport, +})); + +const survey = { id: "survey1", name: "Survey", variables: [], hiddenFields: {} } as unknown as TSurvey; +const response = { id: "response1", data: {}, variables: {} } as unknown as TResponse; + +describe("sendResponseFinishedEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTranslate.mockResolvedValue((key: string) => key); + mockGetElementResponseMapping.mockReturnValue([]); + mockRenderResponseFinishedEmail.mockResolvedValue("rendered"); + mockResolveStorageUrl.mockImplementation((url: string) => `https://cdn.example.com${url}`); + }); + + // This is the exact regression: the organization's whitelabel logo was fetched but never + // threaded into the template, so the notification email always fell back to the hard-coded + // Formbricks logo regardless of what the organization had configured. + test("resolves the organization's whitelabel logo to an absolute URL", async () => { + mockGetOrganizationByWorkspaceId.mockResolvedValue({ + id: "org1", + whitelabel: { logoUrl: "/storage/wsp123/public/logo--fid--abc.png" }, + }); + + await sendResponseFinishedEmail("owner@example.com", "en-US", "workspace1", survey, response, 1); + + expect(mockResolveStorageUrl).toHaveBeenCalledWith("/storage/wsp123/public/logo--fid--abc.png"); + expect(mockRenderResponseFinishedEmail.mock.calls[0][0].logoUrl).toBe( + "https://cdn.example.com/storage/wsp123/public/logo--fid--abc.png" + ); + }); + + test.each([ + ["no whitelabel object", undefined], + ["a whitelabel object with no logo", {}], + ])("leaves the logo unset for %s, so the default Formbricks logo applies", async (_label, whitelabel) => { + mockGetOrganizationByWorkspaceId.mockResolvedValue({ id: "org1", whitelabel }); + + await sendResponseFinishedEmail("owner@example.com", "en-US", "workspace1", survey, response, 1); + + expect(mockResolveStorageUrl).not.toHaveBeenCalled(); + expect(mockRenderResponseFinishedEmail.mock.calls[0][0].logoUrl).toBeUndefined(); + }); +}); diff --git a/packages/email/emails/survey/response-finished-email.tsx b/packages/email/emails/survey/response-finished-email.tsx index f22a06bfaaa4..e68f2d3a4b17 100644 --- a/packages/email/emails/survey/response-finished-email.tsx +++ b/packages/email/emails/survey/response-finished-email.tsx @@ -23,6 +23,7 @@ export interface ResponseFinishedEmailProps extends TEmailTemplateLegalProps { readonly workspaceId: string; readonly organization: TOrganization; readonly elements: ProcessedResponseElement[]; // Pre-processed data, not a function + readonly logoUrl?: string; readonly t?: TFunction; } @@ -45,11 +46,12 @@ export function ResponseFinishedEmail({ workspaceId, organization, elements, + logoUrl, t = mockT, ...legalProps }: ResponseFinishedEmailProps): React.JSX.Element { return ( - + diff --git a/packages/email/src/lib/render.test.ts b/packages/email/src/lib/render.test.ts index ca6868f978c4..f619dea7d8ed 100644 --- a/packages/email/src/lib/render.test.ts +++ b/packages/email/src/lib/render.test.ts @@ -173,6 +173,33 @@ describe("legal footer", () => { }); }); +describe("custom branding", () => { + test("response-finished notification falls back to the Formbricks logo when no organization logo is set", async () => { + const html = await renderResponseFinishedEmail({ + ...exampleData.responseFinishedEmail, + elements: responseFinishedElements, + t, + }); + + expect(html).toContain('data-testid="default-logo-image"'); + expect(html).not.toContain('data-testid="logo-image"'); + }); + + test("response-finished notification renders the organization's custom logo when set", async () => { + const customLogoUrl = "https://example.com/custom-logo.png"; + const html = await renderResponseFinishedEmail({ + ...exampleData.responseFinishedEmail, + elements: responseFinishedElements, + logoUrl: customLogoUrl, + t, + }); + + expect(html).toContain('data-testid="logo-image"'); + expect(html).toContain(customLogoUrl); + expect(html).not.toContain('data-testid="default-logo-image"'); + }); +}); + describe("Tailwind render engine", () => { // `@react-email/tailwind` — not anything configured in this package — decides which // Tailwind version compiles the template classes. Pin that contract: these utilities