Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
17 changes: 10 additions & 7 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,9 @@ jobs:
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash

# Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so
# vite can resolve their package.json `exports` → dist. Deps only (`^...`), not the Next app.
- name: Build workspace package dependencies
if: steps.harness.outputs.present == 'true'
run: pnpm build --filter=@formbricks/web^...
shell: bash

# The .env must exist before the package build: @formbricks/database's prisma.config.ts
# resolves DATABASE_URL at config load (dotenv from the repo root), so `prisma generate`
# fails without it. Same order as e2e.yml.
- name: Create .env
if: steps.harness.outputs.present == 'true'
run: pnpm dev:setup
Expand All @@ -122,6 +118,13 @@ jobs:
sed -i "s|DATABASE_URL=.*|DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks_ba_test?schema=public|" .env
shell: bash

# Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so
# vite can resolve their package.json `exports` → dist. Deps only (`^...`), not the Next app.
- name: Build workspace package dependencies
if: steps.harness.outputs.present == 'true'
run: pnpm build --filter=@formbricks/web^...
shell: bash

- name: Create the test database
if: steps.harness.outputs.present == 'true'
run: psql "postgresql://postgres:postgres@localhost:5432/postgres" -v ON_ERROR_STOP=1 -c 'CREATE DATABASE formbricks_ba_test;'
Expand Down
16 changes: 12 additions & 4 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install --ignore-scripts --frozen-lockfile

# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the database package first. `pnpm generate` loads prisma.config.ts, which
# resolves DATABASE_URL via Prisma's env() helper and throws when it is unset. Mount
# the secret and run through the secret reader so a build-time fallback is supplied
# (mirrors the web build below); prisma generate never connects to the database.
RUN --mount=type=secret,id=database_url \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/database

# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
Expand Down Expand Up @@ -118,8 +122,12 @@ RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json

COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/schema ./packages/database/schema
RUN chown -R nextjs:nextjs ./packages/database/schema && chmod -R 755 ./packages/database/schema

# The migration runner invokes `prisma migrate deploy --config packages/database/prisma.config.ts`
COPY --from=installer /app/packages/database/prisma.config.ts ./packages/database/prisma.config.ts
RUN chown nextjs:nextjs ./packages/database/prisma.config.ts && chmod 644 ./packages/database/prisma.config.ts

COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
RocketIcon,
SettingsIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
Expand Down Expand Up @@ -181,6 +182,20 @@
},
],
},
{
id: "act",
name: t("common.act"),
items: [
{
name: t("common.workflows"),
href: `/workspaces/${workspace.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.startsWith(`/workspaces/${workspace.id}/workflows`),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
Expand Down Expand Up @@ -338,7 +353,7 @@
}
});
},
[router, organization.id, workspace.id]

Check warning on line 356 in apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx

View workflow job for this annotation

GitHub Actions / Run Linters / Linters

React Hook useCallback has an unnecessary dependency: 'workspace.id'. Either exclude it or remove the dependency array
);

const switcherTriggerClasses = cn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { WorkflowsUpgradePrompt } from "@/modules/ee/workflows/components/workflows-upgrade-prompt";
import { WorkspaceWorkflowsHeaderCta } from "@/modules/ee/workflows/components/workspace-workflows-header-cta";
import { WorkspaceWorkflowsSecondaryNavigation } from "@/modules/ee/workflows/components/workspace-workflows-secondary-navigation";
import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkflowsQueryClientProvider } from "./query-client-provider";

const WorkspaceWorkflowsLayout = async (
props: Readonly<{ params: Promise<{ workspaceId: string }>; children: ReactNode }>
) => {
const params = await props.params;
const { isReadOnly, isWorkflowsEnabled, organizationId } = await getWorkflowsRouteAuth(params.workspaceId);
const t = await getTranslate();

if (!isWorkflowsEnabled) {
// Not entitled: keep the page skeleton (title + tabs, both tabs land on this prompt) but render
// the upsell instead of the feature. No CTA and no children — the client pages fetch through
// the now-403 workflows API and must not mount.
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workflows")}>
<WorkspaceWorkflowsSecondaryNavigation workspaceId={params.workspaceId} />
</PageHeader>
<WorkflowsUpgradePrompt organizationId={organizationId} />
</PageContentWrapper>
);
}

return (
<WorkflowsQueryClientProvider>
<PageContentWrapper>
<PageHeader
pageTitle={t("common.workflows")}
cta={<WorkspaceWorkflowsHeaderCta workspaceId={params.workspaceId} isReadOnly={isReadOnly} />}>
<WorkspaceWorkflowsSecondaryNavigation workspaceId={params.workspaceId} />
</PageHeader>
{props.children}
</PageContentWrapper>
</WorkflowsQueryClientProvider>
);
};

export default WorkspaceWorkflowsLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WorkflowsListBodyLoading as default } from "@/modules/ee/workflows/loading";
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
import { WorkflowsListPage } from "@/modules/ee/workflows/pages/workflows-list-page";

const WORKFLOWS_PER_PAGE = 12;

const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isReadOnly, isWorkflowsEnabled } = await getWorkflowsRouteAuth(params.workspaceId);

// Pages render in parallel with the gating layout; contribute nothing when not entitled so the
// client list page (which fetches through the now-403 workflows API) never mounts.
if (!isWorkflowsEnabled) {
return null;
}

return (
<WorkflowsListPage
workspaceId={params.workspaceId}
isReadOnly={isReadOnly}
workflowsPerPage={WORKFLOWS_PER_PAGE}
/>
);
};

export default WorkflowsPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";

interface WorkflowsQueryClientProviderProps {
children: ReactNode;
}

export const WorkflowsQueryClientProvider = ({ children }: Readonly<WorkflowsQueryClientProviderProps>) => {
const [queryClient] = useState(() => new QueryClient());

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WorkspaceWorkflowRunsBodyLoading as default } from "@/modules/ee/workflows/loading";
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { WorkspaceWorkflowRunsPage } from "@/modules/ee/workflows/pages/workspace-workflow-runs-page";

const WorkflowRunsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <WorkspaceWorkflowRunsPage workspaceId={workspaceId} />;
};

export default WorkflowRunsPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { ReactNode } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { WorkflowEditorProvider } from "@/modules/ee/workflows/components/workflow-editor-provider";
import { WorkflowHeaderCta } from "@/modules/ee/workflows/components/workflow-header-cta";
import { WorkflowPageTitle } from "@/modules/ee/workflows/components/workflow-page-title";
import { WorkflowSecondaryNavigation } from "@/modules/ee/workflows/components/workflow-secondary-navigation";
import { WorkflowsUpgradePrompt } from "@/modules/ee/workflows/components/workflows-upgrade-prompt";
import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";

const WorkflowDetailLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string; workflowId: string }>;
children: ReactNode;
}>
) => {
const params = await props.params;
const { isReadOnly, isWorkflowsEnabled, organizationId } = await getWorkflowsRouteAuth(params.workspaceId);

if (!isWorkflowsEnabled) {
// Not entitled: plain title instead of WorkflowPageTitle/WorkflowHeaderCta/WorkflowEditorProvider —
// those fetch the workflow through the now-403 API and must not render broken states. The tabs are
// static links; both land on this prompt, matching the list layout.
const t = await getTranslate();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workflows")}>
<WorkflowSecondaryNavigation workspaceId={params.workspaceId} workflowId={params.workflowId} />
</PageHeader>
<WorkflowsUpgradePrompt organizationId={organizationId} />
</PageContentWrapper>
);
}

return (
<WorkflowEditorProvider>
{/* The editor fills the shell's scroll area instead of scrolling inside it: `h-full` pins this
wrapper to that container's height and the flex column hands whatever is left below the
header to the canvas + inspector row, which scroll internally. Height is therefore derived,
never assumed — the alternative is subtracting a hardcoded guess at the chrome above. */}
<PageContentWrapper className="flex h-full flex-col">
<PageHeader
pageTitle={<WorkflowPageTitle workflowId={params.workflowId} isReadOnly={isReadOnly} />}
cta={<WorkflowHeaderCta workflowId={params.workflowId} isReadOnly={isReadOnly} />}>
<WorkflowSecondaryNavigation workspaceId={params.workspaceId} workflowId={params.workflowId} />
</PageHeader>
{props.children}
</PageContentWrapper>
</WorkflowEditorProvider>
);
};

export default WorkflowDetailLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WorkflowBuilderBodyLoading as default } from "@/modules/ee/workflows/loading";
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
import { getWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/lib/email-authoring-context";
import { WorkflowBuilderPage } from "@/modules/ee/workflows/pages/workflow-builder-page";

const WorkflowPage = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const params = await props.params;
const { isReadOnly, isWorkflowsEnabled } = await getWorkflowsRouteAuth(params.workspaceId);

// Pages render in parallel with the gating layout; skip the server-side context resolution and
// contribute nothing when not entitled so the builder never mounts against the now-403 API.
if (!isWorkflowsEnabled) {
return null;
}

// Resolve the bound survey + team/sender context server-side so the send_email inspector renders
// Follow-Ups-parity controls (recall body, recipient options) from fully-formed props.
const emailAuthoringContext = await getWorkflowEmailAuthoringContext({
workflowId: params.workflowId,
workspaceId: params.workspaceId,
});

return (
<WorkflowBuilderPage
workspaceId={params.workspaceId}
workflowId={params.workflowId}
isReadOnly={isReadOnly}
emailAuthoringContext={emailAuthoringContext}
/>
);
};

export default WorkflowPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WorkflowRunsBodyLoading as default } from "@/modules/ee/workflows/loading";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { WorkflowRunsPage } from "@/modules/ee/workflows/pages/workflow-runs-page";

const WorkflowRuns = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
return <WorkflowRunsPage workspaceId={workspaceId} workflowId={workflowId} />;
};

export default WorkflowRuns;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { WorkflowsQueryClientProvider } from "./query-client-provider";

const WorkflowsLayout = ({ children }: { children: ReactNode }) => {
return <WorkflowsQueryClientProvider>{children}</WorkflowsQueryClientProvider>;
};

export default WorkflowsLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";

export const WorkflowsQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ describe("OAuth protected resource metadata", () => {
scopes_supported: [
"surveys:read",
"surveys:write",
"workflows:read",
"workflows:write",
"feedbackRecords:read",
"feedbackRecords:write",
"offline_access",
Expand Down
Loading
Loading