Compare commits

..

1 Commits

Author SHA1 Message Date
Matti Nannt 2a103cc9f3 fix: move SAML jackson opts out of module scope into init()
`opts` was declared as a module-scope const in a `"use server"` file,
which react-doctor flagged as `server-no-mutable-module-state`
(Server, error). Although the object happens to be read-only today,
the container itself is shared across requests — any future mutation
(e.g. dynamically overriding `db.url`) would silently affect every
request.

Move the object construction inside `init()`, gated by the same
"controllers not yet initialized" check that already wraps the
expensive setup. This is a no-op for cache-hit calls (object is never
constructed) and behaviorally identical for the first call.

The intentional `globalThis` singleton cache for the SAML controllers
themselves is left in place — that's a deliberate cross-request cache,
not unintentional shared state.

Verified via `pnpm --filter @formbricks/web test`: 5166/5166 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:56:40 +02:00
184 changed files with 5746 additions and 20703 deletions
-1
View File
@@ -1 +0,0 @@
../skills
-1
View File
@@ -1 +0,0 @@
../skills
-1
View File
@@ -1 +0,0 @@
../skills
-52
View File
@@ -99,58 +99,6 @@ Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs coloc
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
## Workbench Workflow
The project workbench lives at `workbench/`.
Start substantial tasks with targeted workbench context, not a full-doc sweep. Use `rg`, indexes, headings, and linked records to find the smallest relevant set, then open only the sections needed for the task:
1. `workbench/blueprint/PRODUCT.md`
2. `workbench/blueprint/EPICS.md`
3. `workbench/blueprint/business-rules/`
4. `workbench/blueprint/decisions/`
5. `workbench/cowork/COORDINATOR.md`
6. `workbench/blueprint/MILESTONES.md`
7. The relevant plan, checkpoint, bug-fix, setup, env, security, check, or manual QA file.
`workbench/GUIDE.md` is the workflow source of truth. Keep `AGENTS.md` concise and route detailed workflow rules there.
### Workbench Token Budget
Prefer narrow input and compact output. Do not read entire workbench directories or paste long excerpts unless the task requires it. Summarize findings, link files, and report only changed records plus validation results. For routine implementation, one concise checkpoint is enough after an end-to-end pass.
### Blueprint vs Cowork
- `workbench/blueprint/` contains durable product and application truth: product definition, epics, milestones, business rules, decisions, design, security, checks, manual QA, and env var expectations.
- `workbench/cowork/` contains active execution records: plans, checkpoints, bug fixes, prompts, and coordination.
- `workbench/scratch/` and `workbench/local/` are ignored local spaces. Do not rely on their contents for durable project state.
### Blueprint Human Ownership
AI agents may help improve wording, structure, consistency, and traceability for `workbench/blueprint/` documents. The underlying concepts, original prompts or drafts, and final review must come from humans. Treat blueprint records as human-owned product truth: do not invent product direction, business rules, milestones, decisions, security posture, env expectations, checks, manual QA, or design guidance without explicit human input and review.
### Workbench Review Gates
Do not start planning or implementation from a milestone, plan, or bug-fix record unless it has a completed human review line such as `- [ ] Reviewed and refined by: Javier`. If the line is missing or still says `TBD`, stop and ask for human review. Keep checkpoints proportional: one checkpoint is enough when a plan is implemented end to end in one pass.
### Workbench Validation
After editing `workbench/`, `skills/`, or this workflow section in `AGENTS.md`, run `node workbench/scripts/validate-workbench.mjs workbench` and report any failures or relevant warnings. Before implementing from workbench records, run the validator when the workbench structure, links, or review gates may have changed since the plan was reviewed.
### Documentation Sync
When code changes alter product behavior, business rules, architecture decisions, env vars, setup, security posture, automated checks, or manual QA expectations, update the relevant workbench files in the same change. Avoid process churn: do not create or rewrite workbench docs for purely mechanical implementation details, formatting, or phase-by-phase narration when one concise checkpoint covers an end-to-end implementation.
Use checkpoints for completed plan phases. Use bug-fix records for scoped defects. Use decision records for durable tradeoffs. Use business-rule records for current domain behavior.
## Git Discipline
- Do not create commits unless explicitly asked.
- Preserve staged and unstaged work exactly.
- Do not stage, unstage, reset, or discard files unless explicitly asked.
- Before editing files in an existing repo, inspect `git status --short` and check relevant staged and unstaged diffs.
- Treat staged content as human-selected work in progress.
## Commit & Pull Request Guidelines
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
@@ -13,13 +13,11 @@ import {
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlayCircleIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -189,41 +187,6 @@ export const MainNavigation = ({
},
],
},
{
id: "workflows",
name: (
<span className="inline-flex items-center gap-2">
<span>{t("common.workflows")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="text-[10px] font-semibold normal-case tracking-normal"
/>
</span>
),
items: [
{
name: t("common.workflows"),
href: `/workspaces/${workspace.id}/workflows`,
icon: WorkflowIcon,
isActive:
pathname?.startsWith(`/workspaces/${workspace.id}/workflows`) && !pathname?.includes("/runs"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("workspace.workflows.workflow_runs"),
href: `/workspaces/${workspace.id}/workflows/runs`,
icon: PlayCircleIcon,
isActive:
pathname?.startsWith(`/workspaces/${workspace.id}/workflows/runs`) ||
(pathname?.startsWith(`/workspaces/${workspace.id}/workflows/`) && pathname?.includes("/runs")),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
@@ -1,11 +1,9 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>
) => {
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
@@ -26,8 +26,8 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
} from "lucide-react";
@@ -1,3 +0,0 @@
import { WorkflowBuilderLoading } from "@/modules/workflows/loading";
export default WorkflowBuilderLoading;
@@ -1,13 +0,0 @@
import { WorkflowBuilderPage } from "@/modules/workflows/pages/workflow-builder-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowPage = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
return <WorkflowBuilderPage workspaceId={workspaceId} workflowId={workflowId} isReadOnly={isReadOnly} />;
};
export default WorkflowPage;
@@ -1,3 +0,0 @@
import { WorkflowRunDetailLoading } from "@/modules/workflows/loading";
export default WorkflowRunDetailLoading;
@@ -1,13 +0,0 @@
import { WorkflowRunDetailPage } from "@/modules/workflows/pages/workflow-run-detail-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRunDetail = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string; runId: string }> }>
) => {
const { workspaceId, workflowId, runId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkflowRunDetailPage workspaceId={workspaceId} workflowId={workflowId} runId={runId} />;
};
export default WorkflowRunDetail;
@@ -1,3 +0,0 @@
import { WorkflowRunsLoading } from "@/modules/workflows/loading";
export default WorkflowRunsLoading;
@@ -1,13 +0,0 @@
import { WorkflowRunsPage } from "@/modules/workflows/pages/workflow-runs-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRuns = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkflowRunsPage workspaceId={workspaceId} workflowId={workflowId} />;
};
export default WorkflowRuns;
@@ -1,3 +0,0 @@
import { WorkflowsListLoading } from "@/modules/workflows/loading";
export default WorkflowsListLoading;
@@ -1,11 +0,0 @@
import { WorkflowsListPage } from "@/modules/workflows/pages/workflows-list-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
return <WorkflowsListPage workspaceId={workspaceId} isReadOnly={isReadOnly} />;
};
export default WorkflowsPage;
@@ -1,3 +0,0 @@
import { WorkspaceWorkflowRunsLoading } from "@/modules/workflows/loading";
export default WorkspaceWorkflowRunsLoading;
@@ -1,11 +0,0 @@
import { WorkspaceWorkflowRunsPage } from "@/modules/workflows/pages/workspace-workflow-runs-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRuns = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkspaceWorkflowRunsPage workspaceId={workspaceId} />;
};
export default WorkflowRuns;
@@ -1,59 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { disableWorkflow, getWorkflow } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await disableWorkflow(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow disable unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,59 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { enableWorkflow, getWorkflow } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await enableWorkflow(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (error instanceof z.ZodError || error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow enable unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,157 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowDefinition } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
deleteWorkflow,
getWorkflow,
getWorkflowByWorkspace,
updateWorkflow,
} from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
const ZUpdateWorkflowBody = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(500).nullable().optional(),
definition: ZWorkflowDefinition.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow detail unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const PATCH = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
body: ZUpdateWorkflowBody,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await updateWorkflow({
workflowId,
workspaceId: authResult.workspaceId,
name: parsedInput.body.name,
description: parsedInput.body.description,
definition: parsedInput.body.definition,
});
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return problemBadRequest(requestId, "A workflow with this name already exists", { instance });
}
if (error instanceof z.ZodError || error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow update unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await getWorkflowByWorkspace(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const deletedWorkflow = await deleteWorkflow(workflowId, authResult.workspaceId);
return successResponse(deletedWorkflow, { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,51 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getWorkflow, getWorkflowRun } from "@/modules/workflows/lib/service";
import { serializeWorkflowRun } from "../../../serializers";
const ZWorkflowRunParams = z.object({
workflowId: z.cuid2(),
runId: z.cuid2(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowRunParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const { workflowId, runId } = parsedInput.params;
const log = logger.withContext({ requestId, workflowId, runId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const run = await getWorkflowRun(workflowId, authResult.workspaceId, runId);
if (!run) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflowRun(run), { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow run detail unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,64 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowRunStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successListResponse } from "@/app/api/v3/lib/response";
import { getWorkflow, listWorkflowRuns } from "@/modules/workflows/lib/service";
import { serializeWorkflowRun } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
const ZWorkflowRunsQuery = z.object({
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowRunStatus.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
query: ZWorkflowRunsQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkflowRuns({
workflowId,
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.runs.map(serializeWorkflowRun), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow runs list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
-222
View File
@@ -1,222 +0,0 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { createDefaultWorkflowDefinition } from "@/modules/workflows/lib/default-workflow";
import { createWorkflow, listWorkflows, listWorkspaceWorkflowRuns } from "@/modules/workflows/lib/service";
import { GET, POST } from "./route";
import { GET as GET_WORKSPACE_RUNS } from "./runs/route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/workflows/lib/service", () => ({
createWorkflow: vi.fn(),
listWorkflows: vi.fn(),
listWorkspaceWorkflowRuns: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const workspaceId = "cm8cmpnjj000108jfdr9dfqe8";
const workflowId = "cm8cmpnjj000108jfdr9dfqe7";
function createRequest(url: string, init?: RequestInit): NextRequest {
return new NextRequest(url, init);
}
describe("/api/v3/workflows", () => {
beforeEach(() => {
vi.clearAllMocks();
getServerSession.mockResolvedValue({
expires: "2026-01-01",
user: { email: "u@example.com", id: "user_1", name: "User" },
} as never);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
organizationId: "org_1",
workspaceId,
});
vi.mocked(listWorkflows).mockResolvedValue({ nextCursor: null, workflows: [] } as never);
});
test("lists workflows through workspace-scoped v3 access", async () => {
const req = createRequest(`http://localhost/api/v3/workflows?workspaceId=${workspaceId}&limit=10`);
const res = await GET(req, {} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"read",
expect.any(String),
"/api/v3/workflows"
);
expect(listWorkflows).toHaveBeenCalledWith({
cursor: undefined,
limit: 10,
status: undefined,
workspaceId,
});
});
test("creates draft workflows through the v3 API", async () => {
const definition = createDefaultWorkflowDefinition();
vi.mocked(createWorkflow).mockResolvedValue({
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: "user_1",
description: "Notify the team when a response matches the PoC branch.",
definition,
id: workflowId,
name: "Response completed workflow",
status: "draft",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
} as never);
const req = createRequest("http://localhost/api/v3/workflows", {
body: JSON.stringify({
description: "Notify the team when a response matches the PoC branch.",
definition,
name: "Response completed workflow",
workspaceId,
}),
method: "POST",
});
const res = await POST(req, {} as never);
const body = await res.json();
expect(res.status).toBe(201);
expect(body.data.id).toBe(workflowId);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
expect.any(String),
"/api/v3/workflows"
);
expect(createWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
description: "Notify the team when a response matches the PoC branch.",
definition,
name: "Response completed workflow",
workspaceId,
})
);
});
test("lists workspace workflow runs through workspace-scoped v3 access", async () => {
vi.mocked(listWorkspaceWorkflowRuns).mockResolvedValue({
nextCursor: null,
runs: [
{
createdAt: new Date("2026-04-07T10:00:00.000Z"),
data: { steps: [], logs: [] },
error: null,
finishedAt: null,
id: "cm8cmpnjj000108jfdr9dfqe6",
responseId: "cm8cmpnjj000108jfdr9dfqe5",
startedAt: null,
status: "queued",
surveyId: "cm8cmpnjj000108jfdr9dfqe4",
triggerEvent: "response.completed",
triggerPayload: {},
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workflow: {
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: "user_1",
definition: createDefaultWorkflowDefinition(),
description: null,
id: workflowId,
name: "Response completed workflow",
status: "enabled",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
},
workflowId,
workspaceId,
},
],
} as never);
const req = createRequest(`http://localhost/api/v3/workflows/runs?workspaceId=${workspaceId}&limit=10`);
const res = await GET_WORKSPACE_RUNS(req, {} as never);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data[0].workflow.name).toBe("Response completed workflow");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"read",
expect.any(String),
"/api/v3/workflows/runs"
);
expect(listWorkspaceWorkflowRuns).toHaveBeenCalledWith({
cursor: undefined,
limit: 10,
status: undefined,
workspaceId,
});
});
test("rejects deferred compute actions before service calls", async () => {
const req = createRequest("http://localhost/api/v3/workflows", {
body: JSON.stringify({
definition: {
...createDefaultWorkflowDefinition(),
nodes: [
{
actionType: "compute",
config: {},
id: "compute-1",
type: "action",
},
],
},
name: "Unsupported workflow",
workspaceId,
}),
method: "POST",
});
const res = await POST(req, {} as never);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
expect(createWorkflow).not.toHaveBeenCalled();
});
});
-119
View File
@@ -1,119 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowDefinition, ZWorkflowStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemInternalError,
successListResponse,
successResponse,
} from "@/app/api/v3/lib/response";
import { createWorkflow, listWorkflows } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "./serializers";
const ZWorkflowListQuery = z.object({
workspaceId: z.cuid2(),
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowStatus.optional(),
});
const ZCreateWorkflowBody = z.object({
workspaceId: z.cuid2(),
name: z.string().min(1).max(120),
description: z.string().max(500).nullable().optional(),
status: z.literal("draft").optional(),
definition: ZWorkflowDefinition,
});
const getAuthenticatedUserId = (authentication: unknown): string | undefined => {
if (authentication && typeof authentication === "object" && "user" in authentication) {
const user = (authentication as { user?: { id?: string } }).user;
return user?.id;
}
return undefined;
};
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZWorkflowListQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.query.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.query.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkflows({
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.workflows.map(serializeWorkflow), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflows list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZCreateWorkflowBody,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.body.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await createWorkflow({
workspaceId: authResult.workspaceId,
name: parsedInput.body.name,
description: parsedInput.body.description,
definition: parsedInput.body.definition,
createdBy: getAuthenticatedUserId(authentication),
});
return successResponse(serializeWorkflow(workflow), { requestId, status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return problemBadRequest(requestId, "Invalid workflow definition", { instance });
}
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return problemBadRequest(requestId, "A workflow with this name already exists", { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow create unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,53 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowRunStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemInternalError, successListResponse } from "@/app/api/v3/lib/response";
import { listWorkspaceWorkflowRuns } from "@/modules/workflows/lib/service";
import { serializeWorkflowRunWithWorkflow } from "../serializers";
const ZWorkspaceWorkflowRunsQuery = z.object({
workspaceId: z.cuid2(),
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowRunStatus.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZWorkspaceWorkflowRunsQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.query.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.query.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkspaceWorkflowRuns({
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.runs.map(serializeWorkflowRunWithWorkflow), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workspace workflow runs list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,44 +0,0 @@
import type { Workflow, WorkflowRun } from "@prisma/client";
import { ZWorkflowDefinition, ZWorkflowRunData } from "@formbricks/types/workflows";
const serializeWorkflowRunSummary = (run: WorkflowRun) => ({
id: run.id,
workflowId: run.workflowId,
workspaceId: run.workspaceId,
status: run.status,
triggerEvent: run.triggerEvent,
surveyId: run.surveyId,
responseId: run.responseId,
error: run.error,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
});
export const serializeWorkflow = (workflow: Workflow & { runs?: WorkflowRun[] }) => ({
id: workflow.id,
name: workflow.name,
description: workflow.description,
status: workflow.status,
workspaceId: workflow.workspaceId,
createdBy: workflow.createdBy,
definition: ZWorkflowDefinition.parse(workflow.definition),
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
lastRun: workflow.runs?.[0] ? serializeWorkflowRunSummary(workflow.runs[0]) : null,
});
export const serializeWorkflowRun = (run: WorkflowRun) => ({
...serializeWorkflowRunSummary(run),
triggerPayload: run.triggerPayload,
data: ZWorkflowRunData.parse(run.data),
});
export const serializeWorkflowRunWithWorkflow = (run: WorkflowRun & { workflow: Workflow }) => ({
...serializeWorkflowRun(run),
workflow: {
id: run.workflow.id,
name: run.workflow.name,
},
});
-35
View File
@@ -10,7 +10,6 @@ const mockGetJobsQueueingConfig = vi.fn();
const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const mockProcessSurveySchedulingJob = vi.fn();
const mockProcessWorkflowRunJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise<void>): void => {
@@ -45,10 +44,6 @@ vi.mock("@/modules/survey/scheduling/lib/process-survey-scheduling-job", () => (
processSurveySchedulingJob: mockProcessSurveySchedulingJob,
}));
vi.mock("@/modules/workflows/lib/process-workflow-run-job", () => ({
processWorkflowRunJob: mockProcessWorkflowRunJob,
}));
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
@@ -114,7 +109,6 @@ describe("instrumentation-jobs", () => {
"response-pipeline.process": expect.any(Function),
"survey-scheduling.reconcile": expect.any(Function),
"test-log.process": mockExistingOverride,
"workflow-run.process": expect.any(Function),
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
@@ -122,7 +116,6 @@ describe("instrumentation-jobs", () => {
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
const workflowRunOverride = overrides?.["workflow-run.process"];
await responsePipelineOverride?.(
{
@@ -151,20 +144,6 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
await workflowRunOverride?.(
{
workflowId: "workflow_123",
workflowRunId: "workflow_run_123",
workspaceId: "ws_123",
},
{
attempt: 1,
jobId: "job_789",
jobName: "workflow-run.process",
maxAttempts: 1,
queueName: "background-jobs",
}
);
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
@@ -193,20 +172,6 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
expect(mockProcessWorkflowRunJob).toHaveBeenCalledWith(
{
workflowId: "workflow_123",
workflowRunId: "workflow_run_123",
workspaceId: "ws_123",
},
{
attempt: 1,
jobId: "job_789",
jobName: "workflow-run.process",
maxAttempts: 1,
queueName: "background-jobs",
}
);
});
slowTest("reuses the in-flight startup promise", async () => {
-8
View File
@@ -3,7 +3,6 @@ import {
type JobsRuntimeHandle,
type TResponsePipelineJobData,
type TSurveySchedulingJobData,
type TWorkflowRunJobData,
removeRecurringSurveySchedulingJobSchedule,
startJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule,
@@ -18,7 +17,6 @@ import {
SURVEY_SCHEDULING_TIME_ZONE,
} from "@/modules/survey/scheduling/lib/constants";
import { processSurveySchedulingJob } from "@/modules/survey/scheduling/lib/process-survey-scheduling-job";
import { processWorkflowRunJob } from "@/modules/workflows/lib/process-workflow-run-job";
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
@@ -34,7 +32,6 @@ type TJobsRuntimeGlobal = typeof globalThis & {
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
const WORKFLOW_RUN_JOB_NAME = "workflow-run.process";
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
@@ -42,9 +39,6 @@ const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = asy
const surveySchedulingJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processSurveySchedulingJob(data as TSurveySchedulingJobData, context);
};
const workflowRunJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processWorkflowRunJob(data as TWorkflowRunJobData, context);
};
const registerSurveySchedulingSchedule = async (): Promise<void> => {
await removeRecurringSurveySchedulingJobSchedule({
@@ -176,12 +170,10 @@ export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> =>
...runtimeOptions.jobHandlerOverrides,
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"no_text_found": "Kein Text gefunden",
"none": "None",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht autorisiert",
@@ -506,7 +505,6 @@
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_created_successfully": "Workspace erfolgreich erstellt",
"workspace_creation_description": "Organisiere Umfragen in Workspaces für eine bessere Zugriffskontrolle.",
@@ -3873,75 +3871,6 @@
"value_number": "Wert (Anzahl)",
"value_text": "Wert (Text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Nutze jeden Berührungspunkt, um die Einfachheit der Kundeninteraktion zu verstehen.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none": "None",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
@@ -506,7 +505,6 @@
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace": "Workspace",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
@@ -3873,75 +3871,6 @@
"value_number": "Value (Number)",
"value_text": "Value (Text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Leverage every touchpoint to understand ease of customer interaction.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Sin resultados",
"no_surveys_found": "No se encontraron encuestas.",
"no_text_found": "No se encontró texto",
"none": "None",
"none_of_the_above": "Ninguna de las anteriores",
"not_authenticated": "No estás autenticado para realizar esta acción.",
"not_authorized": "No autorizado",
@@ -506,7 +505,6 @@
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Workflows",
"workspace": "Espacio de trabajo",
"workspace_created_successfully": "Espacio de trabajo creado correctamente",
"workspace_creation_description": "Organiza las encuestas en espacios de trabajo para un mejor control de acceso.",
@@ -3873,75 +3871,6 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Aprovecha cada punto de contacto para entender la facilidad de interacción del cliente.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"no_text_found": "Aucun texte trouvé",
"none": "None",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
@@ -506,7 +505,6 @@
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_created_successfully": "Espace de travail créé avec succès",
"workspace_creation_description": "Organise tes enquêtes dans des espaces de travail pour un meilleur contrôle d'accès.",
@@ -3873,75 +3871,6 @@
"value_number": "Valeur (Nombre)",
"value_text": "Valeur (texte)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Tirez parti de chaque point de contact pour comprendre la facilité d'interaction avec le client.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Nincs találat",
"no_surveys_found": "Nem találhatók kérdőívek.",
"no_text_found": "Nem található szöveg",
"none": "None",
"none_of_the_above": "A fentiek közül egyik sem",
"not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.",
"not_authorized": "Nincs felhatalmazva",
@@ -506,7 +505,6 @@
"website_survey": "Webhely kérdőív",
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Workflows",
"workspace": "Munkaterület",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
@@ -3873,75 +3871,6 @@
"value_number": "Érték (szám)",
"value_text": "Érték (szöveg)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Minden érintkezési pont kihasználása az ügyfelekkel való interakció egyszerűségének megértéséhez.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"no_text_found": "テキストが見つかりません",
"none": "None",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
@@ -506,7 +505,6 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "Workflows",
"workspace": "ワークスペース",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
@@ -3873,75 +3871,6 @@
"value_number": "値(数値)",
"value_text": "値 (テキスト)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "あらゆるタッチポイントを活用して、顧客のインタラクションの容易さを把握します。",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Geen resultaten",
"no_surveys_found": "Geen enquêtes gevonden.",
"no_text_found": "Geen tekst gevonden",
"none": "None",
"none_of_the_above": "Geen van bovenstaande",
"not_authenticated": "U bent niet geverifieerd om deze actie uit te voeren.",
"not_authorized": "Niet geautoriseerd",
@@ -506,7 +505,6 @@
"website_survey": "Website-enquête",
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_created_successfully": "Werkruimte succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
@@ -3873,75 +3871,6 @@
"value_number": "Waarde (getal)",
"value_text": "Waarde (tekst)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Benut elk contactpunt om inzicht te krijgen in het gemak van klantinteractie.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
"no_text_found": "Nenhum texto encontrado",
"none": "None",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Você não está autenticado para realizar essa ação.",
"not_authorized": "Não autorizado",
@@ -506,7 +505,6 @@
"website_survey": "Pesquisa de Site",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Workflows",
"workspace": "Espaço de trabalho",
"workspace_created_successfully": "Workspace criado com sucesso",
"workspace_creation_description": "Organize pesquisas em workspaces para melhor controle de acesso.",
@@ -3873,75 +3871,6 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Aproveite cada ponto de contato para entender a facilidade de interação do cliente.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
"no_text_found": "Nenhum texto encontrado",
"none": "None",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Não está autenticado para realizar esta ação.",
"not_authorized": "Não autorizado",
@@ -506,7 +505,6 @@
"website_survey": "Inquérito do Website",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Workflows",
"workspace": "Espaço de trabalho",
"workspace_created_successfully": "Espaço de trabalho criado com sucesso",
"workspace_creation_description": "Organiza inquéritos em espaços de trabalho para um melhor controlo de acesso.",
@@ -3873,75 +3871,6 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Aproveite todos os pontos de contato para entender a facilidade de interação do cliente.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
"no_text_found": "Niciun text găsit",
"none": "None",
"none_of_the_above": "Niciuna dintre cele de mai sus",
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
"not_authorized": "Neautorizat",
@@ -506,7 +505,6 @@
"website_survey": "Chestionar despre site",
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace": "Spațiu de lucru",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
@@ -3873,75 +3871,6 @@
"value_number": "Valoare (număr)",
"value_text": "Valoare (Text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Valorificați fiecare punct de contact pentru a înțelege ușurința interacțiunilor cu clienții.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Нет результатов",
"no_surveys_found": "Опросы не найдены.",
"no_text_found": "Текст не найден",
"none": "None",
"none_of_the_above": "Ничего из вышеперечисленного",
"not_authenticated": "У вас нет прав для выполнения этого действия.",
"not_authorized": "Нет доступа",
@@ -506,7 +505,6 @@
"website_survey": "Опрос сайта",
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Workflows",
"workspace": "Рабочее пространство",
"workspace_created_successfully": "Рабочее пространство успешно создано",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
@@ -3873,75 +3871,6 @@
"value_number": "Значение (число)",
"value_text": "Значение (текст)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Используйте каждый контакт с клиентом, чтобы понять, насколько легко с вами взаимодействовать.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Inga resultat",
"no_surveys_found": "Inga enkäter hittades.",
"no_text_found": "Ingen text hittades",
"none": "None",
"none_of_the_above": "Inget av ovanstående",
"not_authenticated": "Du är inte autentiserad för att utföra denna åtgärd.",
"not_authorized": "Ej behörig",
@@ -506,7 +505,6 @@
"website_survey": "Webbplatsenkät",
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Workflows",
"workspace": "Arbetsyta",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
@@ -3873,75 +3871,6 @@
"value_number": "Värde (antal)",
"value_text": "Värde (text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Utnyttja varje kontaktpunkt för att förstå hur enkel kundinteraktionen är.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "Sonuç yok",
"no_surveys_found": "Survey bulunamadı.",
"no_text_found": "Metin bulunamadı",
"none": "None",
"none_of_the_above": "Yukarıdakilerin hiçbiri",
"not_authenticated": "Bu işlemi gerçekleştirmek için yetkiniz yok.",
"not_authorized": "Yetkisiz",
@@ -506,7 +505,6 @@
"website_survey": "Web Sitesi Anketi",
"weeks": "hafta",
"welcome_card": "Karşılama kartı",
"workflows": "Workflows",
"workspace": "Çalışma Alanı",
"workspace_created_successfully": "Workspace başarıyla oluşturuldu",
"workspace_creation_description": "Daha iyi erişim kontrolü için survey'leri çalışma alanlarında düzenleyin.",
@@ -3873,75 +3871,6 @@
"value_number": "Değer (Sayı)",
"value_text": "Değer (Metin)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Müşteri etkileşiminin kolaylığını anlamak için her temas noktasından yararlanın.",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"no_text_found": "未找到文本",
"none": "None",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
@@ -506,7 +505,6 @@
"website_survey": "网站 调查",
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "Workflows",
"workspace": "工作区",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
@@ -3873,75 +3871,6 @@
"value_number": "值(数量)",
"value_text": "值(文本)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "客户努力评分",
"ces_description": "利用 每个 接触点 来 了解 客户 互动 的 轻松 程度",
-71
View File
@@ -333,7 +333,6 @@
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
"no_text_found": "找不到文字",
"none": "None",
"none_of_the_above": "以上皆非",
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
@@ -506,7 +505,6 @@
"website_survey": "網站問卷",
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "Workflows",
"workspace": "工作區",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
@@ -3873,75 +3871,6 @@
"value_number": "值(數量)",
"value_text": "值(文字)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "利用每個接觸點來瞭解客戶互動的便利性。",
+11 -11
View File
@@ -5,17 +5,6 @@ import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@/lib/c
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
const opts: JacksonOption = {
externalUrl: WEBAPP_URL,
samlAudience: SAML_AUDIENCE,
samlPath: SAML_PATH,
db: {
engine: "sql",
type: "postgres",
url: SAML_DATABASE_URL,
},
};
declare global {
var oauthController: IOAuthController | undefined;
var connectionController: IConnectionAPIController | undefined;
@@ -28,6 +17,17 @@ export default async function init() {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) return;
const opts: JacksonOption = {
externalUrl: WEBAPP_URL,
samlAudience: SAML_AUDIENCE,
samlPath: SAML_PATH,
db: {
engine: "sql",
type: "postgres",
url: SAML_DATABASE_URL,
},
};
const ret = await (await import("@boxyhq/saml-jackson")).controllers(opts);
await preloadConnection(ret.connectionAPIController);
@@ -20,7 +20,6 @@ import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/l
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { enqueueResponseCompletedWorkflowRunsSafely } from "@/modules/workflows/lib/service";
import { handleIntegrations } from "./handle-integrations";
import { sendTelemetryEvents } from "./telemetry";
@@ -786,13 +785,6 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
});
if (data.event === "responseFinished") {
await enqueueResponseCompletedWorkflowRunsSafely({
workspaceId: data.workspaceId,
surveyId: data.surveyId,
response: data.response,
logContext,
});
await runResponseFinishedSideEffects({
data,
logContext,
@@ -2,7 +2,7 @@ import { cn } from "@/lib/cn";
interface BadgeProps {
text: string;
type: "warning" | "success" | "error" | "gray" | "info";
type: "warning" | "success" | "error" | "gray";
size: "tiny" | "normal" | "large";
className?: string;
role?: string;
@@ -14,7 +14,6 @@ export const Badge: React.FC<BadgeProps> = ({ text, type, size, className, role
success: "bg-green-50",
error: "bg-red-100",
gray: "bg-slate-100",
info: "bg-blue-50",
};
const borderColor = {
@@ -22,7 +21,6 @@ export const Badge: React.FC<BadgeProps> = ({ text, type, size, className, role
success: "border-green-600",
error: "border-red-200",
gray: "border-slate-200",
info: "border-blue-200",
};
const textColor = {
@@ -30,7 +28,6 @@ export const Badge: React.FC<BadgeProps> = ({ text, type, size, className, role
success: "text-green-800",
error: "text-red-800",
gray: "text-slate-600",
info: "text-blue-800",
};
const padding = {
@@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "@/modules/ui/lib/utils";
const buttonVariants = cva(
"relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -50,14 +50,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(buttonVariants({ variant, size, loading, className }))}
ref={ref}
{...props}
aria-busy={loading || undefined}
disabled={loading || disabled}>
{loading ? (
<>
<span className="absolute inset-0 z-[1] flex items-center justify-center">
<Loader2 className="animate-spin" />
</span>
<span className="flex select-none gap-2 opacity-40">{children}</span>
<Loader2 className="animate-spin" />
{children}
</>
) : (
children
-2
View File
@@ -1,5 +1,3 @@
@import "@xyflow/react/dist/style.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -1,95 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
type TCreateWorkflowDialogProps = Readonly<{
open: boolean;
onOpenChange: (open: boolean) => void;
workflowName: string;
workflowDescription: string;
onWorkflowNameChange: (name: string) => void;
onWorkflowDescriptionChange: (description: string) => void;
onCreate: () => void;
isCreating: boolean;
}>;
export const CreateWorkflowDialog = ({
open,
onOpenChange,
workflowName,
workflowDescription,
onWorkflowNameChange,
onWorkflowDescriptionChange,
onCreate,
isCreating,
}: TCreateWorkflowDialogProps) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow">
<DialogHeader>
<DialogTitle>{t("workspace.workflows.create_workflow")}</DialogTitle>
<DialogDescription>{t("workspace.workflows.create_workflow_description")}</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
if (workflowName.trim() && !isCreating) {
onCreate();
}
}}
className="space-y-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workflow-name">{t("workspace.workflows.workflow_name")}</Label>
<Input
id="workflow-name"
placeholder={t("workspace.workflows.workflow_name_placeholder")}
value={workflowName}
onChange={(event) => onWorkflowNameChange(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-description">
{t("workspace.workflows.workflow_description_optional")}
</Label>
<Input
id="workflow-description"
placeholder={t("workspace.workflows.workflow_description_placeholder")}
value={workflowDescription}
onChange={(event) => onWorkflowDescriptionChange(event.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isCreating}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isCreating} disabled={!workflowName.trim()}>
{t("common.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -1,83 +0,0 @@
"use client";
import { PauseIcon, PencilIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TWorkflowRunStatus, TWorkflowStatus } from "@formbricks/types/workflows";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
export const WorkflowStatusBadge = ({ status }: Readonly<{ status: TWorkflowStatus }>) => {
const { t } = useTranslation();
const labels = {
draft: t("workspace.workflows.status.draft"),
enabled: t("workspace.workflows.status.enabled"),
disabled: t("workspace.workflows.status.disabled"),
};
const types = {
draft: "gray",
enabled: "success",
disabled: "warning",
} as const;
return <Badge text={labels[status]} type={types[status]} size="normal" />;
};
export const WorkflowStatusPill = ({ status }: Readonly<{ status: TWorkflowStatus }>) => {
const { t } = useTranslation();
const labels = {
draft: t("workspace.workflows.status.draft"),
enabled: t("workspace.workflows.status.enabled"),
disabled: t("workspace.workflows.status.disabled"),
};
return (
<span
className={cn(
"flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
status === "enabled" && "bg-emerald-50",
status === "draft" && "bg-slate-100",
status === "disabled" && "bg-slate-100"
)}>
{status === "enabled" ? (
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
) : (
<span
className={cn(
"rounded-full p-1",
status === "draft" && "bg-slate-300",
status === "disabled" && "bg-slate-200"
)}>
{status === "draft" ? (
<PencilIcon className="h-3 w-3 text-slate-600" />
) : (
<PauseIcon className="h-3 w-3 text-slate-600" />
)}
</span>
)}
{labels[status]}
</span>
);
};
export const WorkflowRunStatusBadge = ({ status }: Readonly<{ status: TWorkflowRunStatus }>) => {
const { t } = useTranslation();
const labels = {
queued: t("workspace.workflows.run_status.queued"),
running: t("workspace.workflows.run_status.running"),
completed: t("workspace.workflows.run_status.completed"),
failed: t("workspace.workflows.run_status.failed"),
canceled: t("workspace.workflows.run_status.canceled"),
};
const types = {
queued: "gray",
running: "warning",
completed: "success",
failed: "error",
canceled: "gray",
} as const;
return <Badge text={labels[status]} type={types[status]} size="normal" />;
};
@@ -1,63 +0,0 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import type { TWorkflowRun } from "../types/workflows";
import { WorkflowRunStatusBadge } from "./status-badges";
type TWorkflowRunsTableProps = Readonly<{
runs: TWorkflowRun[];
workspaceId: string;
workflowId?: string;
showWorkflowColumn?: boolean;
}>;
export const WorkflowRunsTable = ({
runs,
workspaceId,
workflowId,
showWorkflowColumn = false,
}: TWorkflowRunsTableProps) => {
const { t, i18n } = useTranslation();
const runColumnSpan = showWorkflowColumn ? "col-span-2" : "col-span-3";
const workflowColumn = showWorkflowColumn ? (
<div className="col-span-2">{t("workspace.workflows.workflow")}</div>
) : null;
return (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
<div className="grid grid-cols-12 gap-4 border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-600">
<div className={runColumnSpan}>{t("workspace.workflows.run")}</div>
{workflowColumn}
<div className="col-span-2">{t("common.status")}</div>
<div className="col-span-3">{t("common.created_at")}</div>
<div className="col-span-2">{t("workspace.workflows.response")}</div>
<div className="col-span-1">{t("workspace.workflows.error")}</div>
</div>
{runs.map((run) => {
const targetWorkflowId = workflowId ?? run.workflow?.id ?? run.workflowId;
return (
<Link
key={run.id}
href={`/workspaces/${workspaceId}/workflows/${targetWorkflowId}/runs/${run.id}`}
className="grid grid-cols-12 gap-4 border-b border-slate-100 px-4 py-4 text-sm last:border-b-0 hover:bg-slate-50">
<div className={`${runColumnSpan} truncate font-medium text-slate-900`}>{run.id}</div>
{showWorkflowColumn ? (
<div className="col-span-2 truncate text-slate-600">{run.workflow?.name ?? run.workflowId}</div>
) : null}
<div className="col-span-2">
<WorkflowRunStatusBadge status={run.status} />
</div>
<div className="col-span-3 text-slate-600">
{formatDateTimeForDisplay(new Date(run.createdAt), i18n.resolvedLanguage)}
</div>
<div className="col-span-2 truncate text-slate-600">{run.responseId ?? t("common.none")}</div>
<div className="col-span-1 truncate text-slate-600">{run.error ?? t("common.none")}</div>
</Link>
);
})}
</div>
);
};
@@ -1,34 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
export const WorkflowSecondaryNavigation = ({
workspaceId,
workflowId,
activeId,
}: Readonly<{
workspaceId: string;
workflowId: string;
activeId: "builder" | "runs";
}>) => {
const { t } = useTranslation();
return (
<SecondaryNavigation
activeId={activeId}
navigation={[
{
id: "builder",
label: t("workspace.workflows.builder"),
href: `/workspaces/${workspaceId}/workflows/${workflowId}`,
},
{
id: "runs",
label: t("workspace.workflows.runs"),
href: `/workspaces/${workspaceId}/workflows/${workflowId}/runs`,
},
]}
/>
);
};
@@ -1,163 +0,0 @@
"use client";
import type { TWorkflowDefinition, TWorkflowRunStatus, TWorkflowStatus } from "@formbricks/types/workflows";
import { parseV3ApiError } from "@/modules/api/lib/v3-client";
import type { TWorkflow, TWorkflowRun } from "../types/workflows";
type TListResponse<T> = {
data: T[];
meta: {
limit: number;
nextCursor: string | null;
};
};
type TItemResponse<T> = {
data: T;
};
const parseJsonResponse = async <T>(response: Response): Promise<T> => {
if (!response.ok) {
throw await parseV3ApiError(response);
}
return (await response.json()) as T;
};
export const listWorkflows = async ({
workspaceId,
status,
}: {
workspaceId: string;
status?: TWorkflowStatus;
}): Promise<TListResponse<TWorkflow>> => {
const params = new URLSearchParams({ workspaceId });
if (status) {
params.set("status", status);
}
const response = await fetch(`/api/v3/workflows?${params.toString()}`, {
cache: "no-store",
});
return await parseJsonResponse<TListResponse<TWorkflow>>(response);
};
export const createWorkflow = async ({
workspaceId,
name,
description,
definition,
}: {
workspaceId: string;
name: string;
description?: string | null;
definition: TWorkflowDefinition;
}): Promise<TWorkflow> => {
const response = await fetch("/api/v3/workflows", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ workspaceId, name, description, definition }),
});
const result = await parseJsonResponse<TItemResponse<TWorkflow>>(response);
return result.data;
};
export const getWorkflow = async (workflowId: string): Promise<TWorkflow> => {
const response = await fetch(`/api/v3/workflows/${workflowId}`, {
cache: "no-store",
});
const result = await parseJsonResponse<TItemResponse<TWorkflow>>(response);
return result.data;
};
export const updateWorkflow = async ({
workflowId,
name,
description,
definition,
}: {
workflowId: string;
name?: string;
description?: string | null;
definition?: TWorkflowDefinition;
}): Promise<TWorkflow> => {
const response = await fetch(`/api/v3/workflows/${workflowId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, description, definition }),
});
const result = await parseJsonResponse<TItemResponse<TWorkflow>>(response);
return result.data;
};
export const deleteWorkflow = async (workflowId: string): Promise<{ id: string }> => {
const response = await fetch(`/api/v3/workflows/${workflowId}`, {
method: "DELETE",
});
const result = await parseJsonResponse<TItemResponse<{ id: string }>>(response);
return result.data;
};
export const enableWorkflow = async (workflowId: string): Promise<TWorkflow> => {
const response = await fetch(`/api/v3/workflows/${workflowId}/enable`, {
method: "POST",
});
const result = await parseJsonResponse<TItemResponse<TWorkflow>>(response);
return result.data;
};
export const disableWorkflow = async (workflowId: string): Promise<TWorkflow> => {
const response = await fetch(`/api/v3/workflows/${workflowId}/disable`, {
method: "POST",
});
const result = await parseJsonResponse<TItemResponse<TWorkflow>>(response);
return result.data;
};
export const listWorkflowRuns = async ({
workflowId,
status,
}: {
workflowId: string;
status?: TWorkflowRunStatus;
}): Promise<TListResponse<TWorkflowRun>> => {
const params = new URLSearchParams();
if (status) {
params.set("status", status);
}
const response = await fetch(`/api/v3/workflows/${workflowId}/runs?${params.toString()}`, {
cache: "no-store",
});
return await parseJsonResponse<TListResponse<TWorkflowRun>>(response);
};
export const listWorkspaceWorkflowRuns = async ({
workspaceId,
status,
}: {
workspaceId: string;
status?: TWorkflowRunStatus;
}): Promise<TListResponse<TWorkflowRun>> => {
const params = new URLSearchParams({ workspaceId });
if (status) {
params.set("status", status);
}
const response = await fetch(`/api/v3/workflows/runs?${params.toString()}`, {
cache: "no-store",
});
return await parseJsonResponse<TListResponse<TWorkflowRun>>(response);
};
export const getWorkflowRun = async (workflowId: string, runId: string): Promise<TWorkflowRun> => {
const response = await fetch(`/api/v3/workflows/${workflowId}/runs/${runId}`, {
cache: "no-store",
});
const result = await parseJsonResponse<TItemResponse<TWorkflowRun>>(response);
return result.data;
};
@@ -1,90 +0,0 @@
import type { TWorkflowDefinition } from "@formbricks/types/workflows";
export const createDefaultWorkflowDefinition = (): TWorkflowDefinition => ({
schemaVersion: 1,
entryNodeId: "trigger-response-completed",
trigger: {
id: "trigger-response-completed",
type: "trigger",
config: {
type: "response.completed",
},
ui: {
position: { x: 120, y: 80 },
},
},
nodes: [
{
id: "if-response-finished",
type: "ifElse",
config: {
condition: {
id: "group-response-finished",
connector: "and",
conditions: [
{
id: "condition-response-finished",
left: {
type: "ref",
path: "trigger.response.finished",
},
operator: "equals",
right: true,
},
],
},
},
ui: {
position: { x: 120, y: 240 },
},
},
{
id: "send-email-preview",
type: "action",
actionType: "sendEmailPreview",
config: {
to: "team@example.com",
replyTo: [],
subject: "Response completed",
body: "A response completed this workflow path.",
includeResponseData: true,
},
ui: {
position: { x: -120, y: 420 },
},
},
{
id: "send-webhook-preview",
type: "action",
actionType: "sendWebhookPreview",
config: {
url: "https://example.com/workflow-preview",
method: "POST",
headers: {},
},
ui: {
position: { x: 360, y: 420 },
},
},
],
edges: [
{
id: "edge-trigger-if",
source: "trigger-response-completed",
target: "if-response-finished",
branch: "next",
},
{
id: "edge-if-email",
source: "if-response-finished",
target: "send-email-preview",
branch: "then",
},
{
id: "edge-if-webhook",
source: "if-response-finished",
target: "send-webhook-preview",
branch: "else",
},
],
});
@@ -1,78 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { createDefaultWorkflowDefinition } from "./default-workflow";
import { executeWorkflowDefinition } from "./executor";
const triggerPayload = {
event: "response.completed",
workspaceId: "cm8cmpnjj000108jfdr9dfqe8",
surveyId: "cm8cmpnjj000108jfdr9dfqe7",
response: {
id: "cm8cmpnjj000108jfdr9dfqe6",
finished: true,
data: {
score: 10,
},
},
};
describe("executeWorkflowDefinition", () => {
test("selects the then path and records an email preview without sending", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const result = executeWorkflowDefinition(createDefaultWorkflowDefinition(), triggerPayload);
expect(result.status).toBe("completed");
expect(result.steps.map((step) => step.nodeId)).toEqual([
"trigger-response-completed",
"if-response-finished",
"send-email-preview",
]);
expect(result.finalOutput).toEqual(
expect.objectContaining({
actionType: "sendEmailPreview",
preview: true,
sent: false,
})
);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
test("selects the else path and wraps the previous output in the webhook preview envelope", () => {
const result = executeWorkflowDefinition(createDefaultWorkflowDefinition(), {
...triggerPayload,
response: {
...triggerPayload.response,
finished: false,
},
});
expect(result.status).toBe("completed");
expect(result.steps.map((step) => step.nodeId)).toEqual([
"trigger-response-completed",
"if-response-finished",
"send-webhook-preview",
]);
expect(result.finalOutput).toEqual({
actionType: "sendWebhookPreview",
body: {
response: {
branch: "else",
matched: false,
},
},
headers: {},
method: "POST",
preview: true,
sent: false,
url: "https://example.com/workflow-preview",
});
});
test("fails invalid workflow definitions before execution", () => {
const result = executeWorkflowDefinition({ schemaVersion: 1 }, triggerPayload);
expect(result.status).toBe("failed");
expect(result.steps).toEqual([]);
expect(result.error).toBeDefined();
});
});
-271
View File
@@ -1,271 +0,0 @@
import {
type TIfElseNode,
type TWorkflowCondition,
type TWorkflowConditionGroup,
type TWorkflowDefinition,
type TWorkflowEdge,
type TWorkflowNode,
type TWorkflowStepResult,
ZWorkflowDefinition,
} from "@formbricks/types/workflows";
type TWorkflowExecutionContext = {
triggerPayload: unknown;
};
export type TWorkflowExecutionResult = {
status: "completed" | "failed";
steps: TWorkflowStepResult[];
finalOutput?: unknown;
error?: string;
};
const getTimestamp = (): string => new Date().toISOString();
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const resolveWorkflowDataPath = (input: unknown, path: string): unknown => {
const normalizedPath = path.startsWith("trigger.") ? path.slice("trigger.".length) : path;
return normalizedPath.split(".").reduce<unknown>((current, segment) => {
if (!isRecord(current)) {
return undefined;
}
return current[segment];
}, input);
};
const compareValues = (left: unknown, operator: TWorkflowCondition["operator"], right?: unknown): boolean => {
const rightValue = isRecord(right) && right.type === "ref" ? undefined : right;
switch (operator) {
case "equals":
return left === rightValue;
case "notEquals":
return left !== rightValue;
case "lessThan":
return Number(left) < Number(rightValue);
case "lessEqual":
return Number(left) <= Number(rightValue);
case "greaterThan":
return Number(left) > Number(rightValue);
case "greaterEqual":
return Number(left) >= Number(rightValue);
case "contains":
return typeof left === "string" && typeof rightValue === "string"
? left.includes(rightValue)
: Array.isArray(left) && left.includes(rightValue);
case "doesNotContain":
return !compareValues(left, "contains", rightValue);
case "isEmpty":
return left === undefined || left === null || left === "" || (Array.isArray(left) && left.length === 0);
case "isNotEmpty":
return !compareValues(left, "isEmpty");
}
};
const evaluateCondition = (condition: TWorkflowCondition, context: TWorkflowExecutionContext): boolean => {
const left = resolveWorkflowDataPath(context.triggerPayload, condition.left.path);
const right =
isRecord(condition.right) && condition.right.type === "ref"
? resolveWorkflowDataPath(context.triggerPayload, condition.right.path as string)
: condition.right;
return compareValues(left, condition.operator, right);
};
export const evaluateConditionGroup = (
group: TWorkflowConditionGroup,
context: TWorkflowExecutionContext
): boolean => {
const results = group.conditions.map((conditionOrGroup) => {
if ("connector" in conditionOrGroup) {
return evaluateConditionGroup(conditionOrGroup, context);
}
return evaluateCondition(conditionOrGroup, context);
});
return group.connector === "and" ? results.every(Boolean) : results.some(Boolean);
};
const getNode = (definition: TWorkflowDefinition, nodeId: string): TWorkflowNode | "trigger" | undefined => {
if (nodeId === definition.trigger.id) {
return "trigger";
}
return definition.nodes.find((node) => node.id === nodeId);
};
const getOutgoingEdge = (
definition: TWorkflowDefinition,
nodeId: string,
branch: TWorkflowEdge["branch"]
): TWorkflowEdge | undefined =>
definition.edges.find((edge) => edge.source === nodeId && edge.branch === branch);
const executeIfElseNode = (
node: TIfElseNode,
context: TWorkflowExecutionContext
): { output: { matched: boolean; branch: "then" | "else" }; branch: "then" | "else" } => {
const matched = evaluateConditionGroup(node.config.condition, context);
const branch = matched ? "then" : "else";
return {
branch,
output: {
matched,
branch,
},
};
};
const executeActionNode = (
node: TWorkflowNode,
previousOutput: unknown,
triggerPayload: unknown
): unknown => {
if (node.type !== "action") {
return undefined;
}
if (node.actionType === "sendEmailPreview") {
return {
preview: true,
sent: false,
actionType: node.actionType,
to: node.config.to,
replyTo: node.config.replyTo,
subject: node.config.subject,
body: node.config.body,
response: node.config.includeResponseData ? (previousOutput ?? triggerPayload) : undefined,
};
}
return {
preview: true,
sent: false,
actionType: node.actionType,
url: node.config.url,
method: node.config.method,
headers: node.config.headers,
body: {
response: previousOutput ?? triggerPayload,
},
};
};
export const executeWorkflowDefinition = (
rawDefinition: unknown,
triggerPayload: unknown
): TWorkflowExecutionResult => {
const parsedDefinition = ZWorkflowDefinition.safeParse(rawDefinition);
if (!parsedDefinition.success) {
return {
status: "failed",
steps: [],
error: parsedDefinition.error.issues.map((issue) => issue.message).join("; "),
};
}
const definition = parsedDefinition.data;
const context = { triggerPayload };
const steps: TWorkflowStepResult[] = [];
const visitedNodeIds = new Set<string>();
let currentNodeId: string | undefined = definition.entryNodeId;
let previousOutput: unknown = triggerPayload;
while (currentNodeId) {
if (visitedNodeIds.has(currentNodeId)) {
return {
status: "failed",
steps,
finalOutput: previousOutput,
error: `Workflow graph cycle detected at node ${currentNodeId}`,
};
}
visitedNodeIds.add(currentNodeId);
const node = getNode(definition, currentNodeId);
if (!node) {
return {
status: "failed",
steps,
finalOutput: previousOutput,
error: `Workflow node ${currentNodeId} was not found`,
};
}
const startedAt = getTimestamp();
try {
if (node === "trigger") {
const finishedAt = getTimestamp();
steps.push({
nodeId: definition.trigger.id,
status: "completed",
input: triggerPayload,
output: triggerPayload,
startedAt,
finishedAt,
});
previousOutput = triggerPayload;
currentNodeId = getOutgoingEdge(definition, definition.trigger.id, "next")?.target;
continue;
}
if (node.type === "ifElse") {
const result = executeIfElseNode(node, context);
const finishedAt = getTimestamp();
steps.push({
nodeId: node.id,
status: "completed",
input: triggerPayload,
output: result.output,
startedAt,
finishedAt,
});
previousOutput = result.output;
currentNodeId = getOutgoingEdge(definition, node.id, result.branch)?.target;
continue;
}
const output = executeActionNode(node, previousOutput, triggerPayload);
const finishedAt = getTimestamp();
steps.push({
nodeId: node.id,
status: "completed",
input: previousOutput,
output,
startedAt,
finishedAt,
});
previousOutput = output;
currentNodeId = getOutgoingEdge(definition, node.id, "next")?.target;
} catch (error) {
const message = error instanceof Error ? error.message : "Workflow step failed";
const finishedAt = getTimestamp();
steps.push({
nodeId: currentNodeId ?? "unknown",
status: "failed",
input: previousOutput,
error: message,
startedAt,
finishedAt,
});
return {
status: "failed",
steps,
finalOutput: previousOutput,
error: message,
};
}
}
return {
status: "completed",
steps,
finalOutput: previousOutput,
};
};
@@ -1,7 +0,0 @@
import "server-only";
import type { JobHandler, TWorkflowRunJobData } from "@formbricks/jobs";
import { processWorkflowRun } from "./service";
export const processWorkflowRunJob: JobHandler<TWorkflowRunJobData> = async (data) => {
await processWorkflowRun(data);
};
@@ -1,235 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { enqueueWorkflowRunJob } from "@formbricks/jobs";
import { createDefaultWorkflowDefinition } from "./default-workflow";
import {
enqueueResponseCompletedWorkflowRuns,
enqueueResponseCompletedWorkflowRunsSafely,
processWorkflowRun,
} from "./service";
const { mockLoggerError } = vi.hoisted(() => ({
mockLoggerError: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
workflow: {
findMany: vi.fn(),
},
workflowRun: {
create: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/jobs", () => ({
enqueueWorkflowRunJob: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mockLoggerError,
},
}));
const workspaceId = "cm8cmpnjj000108jfdr9dfqe8";
const surveyId = "cm8cmpnjj000108jfdr9dfqe7";
const responseId = "cm8cmpnjj000108jfdr9dfqe6";
const workflowId = "cm8cmpnjj000108jfdr9dfqe5";
const workflowRunId = "cm8cmpnjj000108jfdr9dfqe4";
const response = {
contact: null,
contactAttributes: null,
createdAt: new Date("2026-04-07T10:00:00.000Z"),
data: {},
displayId: null,
endingId: null,
finished: true,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
tags: [],
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
variables: {},
};
const workflow = {
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: null,
description: null,
definition: createDefaultWorkflowDefinition(),
id: workflowId,
name: "Response completed workflow",
status: "enabled",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
};
describe("workflow service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("creates queued runs and enqueues jobs for enabled matching response-completed workflows", async () => {
vi.mocked(prisma.workflow.findMany).mockResolvedValue([workflow] as never);
vi.mocked(prisma.workflowRun.create).mockResolvedValue({
...workflow,
id: workflowRunId,
workflowId,
triggerPayload: {},
} as never);
vi.mocked(enqueueWorkflowRunJob).mockResolvedValue({ id: "job-1" } as never);
const runs = await enqueueResponseCompletedWorkflowRuns({ workspaceId, surveyId, response });
expect(prisma.workflow.findMany).toHaveBeenCalledWith({
where: {
status: "enabled",
workspaceId,
},
});
expect(prisma.workflowRun.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
responseId,
status: "queued",
surveyId,
triggerEvent: "response.completed",
workflowId,
workspaceId,
}),
})
);
expect(enqueueWorkflowRunJob).toHaveBeenCalledWith({ workflowId, workflowRunId, workspaceId });
expect(runs).toHaveLength(1);
});
test("does not enqueue when the trigger survey filter does not match", async () => {
vi.mocked(prisma.workflow.findMany).mockResolvedValue([
{
...workflow,
definition: {
...createDefaultWorkflowDefinition(),
trigger: {
...createDefaultWorkflowDefinition().trigger,
config: {
type: "response.completed",
surveyId: "cm8cmpnjj000108jfdr9dfqe3",
},
},
},
},
] as never);
const runs = await enqueueResponseCompletedWorkflowRuns({ workspaceId, surveyId, response });
expect(runs).toEqual([]);
expect(prisma.workflowRun.create).not.toHaveBeenCalled();
expect(enqueueWorkflowRunJob).not.toHaveBeenCalled();
});
test("safe enqueue logs and does not throw", async () => {
vi.mocked(prisma.workflow.findMany).mockRejectedValue(new Error("database unavailable"));
await expect(
enqueueResponseCompletedWorkflowRunsSafely({
workspaceId,
surveyId,
response,
logContext: { jobId: "job-response" },
})
).resolves.toBeUndefined();
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "job-response",
}),
"Response Completed workflow enqueue failed"
);
});
test("processes workflow runs through running and completed statuses", async () => {
vi.mocked(prisma.workflowRun.findFirst).mockResolvedValue({
...workflow,
data: { steps: [], logs: [] },
id: workflowRunId,
triggerPayload: {
event: "response.completed",
response,
surveyId,
workspaceId,
},
workflow,
workflowId,
} as never);
vi.mocked(prisma.workflowRun.updateMany).mockResolvedValue({ count: 1 } as never);
await processWorkflowRun({ workflowId, workflowRunId, workspaceId });
expect(prisma.workflowRun.updateMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
data: expect.objectContaining({
status: "running",
}),
where: {
id: workflowRunId,
workflowId,
workspaceId,
},
})
);
expect(prisma.workflowRun.updateMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
data: expect.objectContaining({
status: "completed",
}),
where: {
id: workflowRunId,
workflowId,
workspaceId,
},
})
);
});
test("marks workflow runs failed when execution fails", async () => {
vi.mocked(prisma.workflowRun.findFirst).mockResolvedValue({
...workflow,
data: { steps: [], logs: [] },
id: workflowRunId,
triggerPayload: {},
workflow: {
...workflow,
definition: { schemaVersion: 1 },
},
workflowId,
} as never);
vi.mocked(prisma.workflowRun.updateMany).mockResolvedValue({ count: 1 } as never);
await expect(processWorkflowRun({ workflowId, workflowRunId, workspaceId })).rejects.toThrow();
expect(prisma.workflowRun.updateMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
data: expect.objectContaining({
status: "failed",
}),
where: {
id: workflowRunId,
workflowId,
workspaceId,
},
})
);
});
});
-450
View File
@@ -1,450 +0,0 @@
import "server-only";
import { Prisma, type WorkflowRunStatus, type WorkflowStatus } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { type TResponsePipelineJobData, enqueueWorkflowRunJob } from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import {
type TWorkflowDefinition,
type TWorkflowRunData,
type TWorkflowStatus,
type TWorkflowTriggerPayload,
ZWorkflowDefinition,
ZWorkflowRunData,
ZWorkflowStatus,
ZWorkflowTriggerPayload,
} from "@formbricks/types/workflows";
import { executeWorkflowDefinition } from "./executor";
const DEFAULT_PAGE_LIMIT = 20;
const MAX_PAGE_LIMIT = 100;
const RESPONSE_COMPLETED_TRIGGER_EVENT = "response.completed";
type TWorkflowListParams = {
workspaceId: string;
status?: TWorkflowStatus;
limit?: number;
cursor?: string;
};
type TWorkflowRunListParams = {
workflowId: string;
workspaceId: string;
status?: WorkflowRunStatus;
limit?: number;
cursor?: string;
};
type TWorkspaceWorkflowRunListParams = Omit<TWorkflowRunListParams, "workflowId">;
const getPageLimit = (limit?: number): number =>
Math.min(Math.max(limit ?? DEFAULT_PAGE_LIMIT, 1), MAX_PAGE_LIMIT);
const toJsonInput = (value: unknown): Prisma.InputJsonValue =>
JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const validateDefinition = (definition: unknown): TWorkflowDefinition =>
ZWorkflowDefinition.parse(definition);
const parseRunData = (data: unknown): TWorkflowRunData => {
const parsed = ZWorkflowRunData.safeParse(data);
return parsed.success ? parsed.data : { steps: [], logs: [] };
};
export const listWorkflows = async ({ workspaceId, status, limit, cursor }: TWorkflowListParams) => {
const take = getPageLimit(limit);
const rows = await prisma.workflow.findMany({
where: {
workspaceId,
...(status ? { status: status as WorkflowStatus } : {}),
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: take + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
include: {
runs: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
const nextCursor = rows.length > take ? rows[take]?.id : null;
return {
workflows: rows.slice(0, take),
nextCursor,
};
};
export const getWorkflow = async (workflowId: string) => {
return await prisma.workflow.findUnique({
where: {
id: workflowId,
},
});
};
export const getWorkflowByWorkspace = async (workflowId: string, workspaceId: string) => {
return await prisma.workflow.findUnique({
where: {
id_workspaceId: {
id: workflowId,
workspaceId,
},
},
});
};
export const createWorkflow = async ({
workspaceId,
name,
description,
definition,
createdBy,
}: {
workspaceId: string;
name: string;
description?: string | null;
definition: unknown;
createdBy?: string;
}) => {
const parsedDefinition = validateDefinition(definition);
return await prisma.workflow.create({
data: {
workspaceId,
name,
description,
status: "draft",
definition: toJsonInput(parsedDefinition),
createdBy,
},
});
};
export const updateWorkflow = async ({
workflowId,
workspaceId,
name,
description,
definition,
}: {
workflowId: string;
workspaceId: string;
name?: string;
description?: string | null;
definition?: unknown;
}) => {
const workflow = await getWorkflowByWorkspace(workflowId, workspaceId);
if (!workflow) {
return null;
}
if (workflow.status === "enabled") {
throw new Error("Enabled workflows must be disabled before editing.");
}
const parsedDefinition = definition === undefined ? undefined : validateDefinition(definition);
return await prisma.workflow.update({
where: {
id_workspaceId: {
id: workflowId,
workspaceId,
},
},
data: {
...(name !== undefined ? { name } : {}),
...(description !== undefined ? { description } : {}),
...(parsedDefinition !== undefined ? { definition: toJsonInput(parsedDefinition) } : {}),
},
});
};
export const deleteWorkflow = async (workflowId: string, workspaceId: string) => {
return await prisma.workflow.delete({
where: {
id_workspaceId: {
id: workflowId,
workspaceId,
},
},
select: {
id: true,
},
});
};
export const enableWorkflow = async (workflowId: string, workspaceId: string) => {
const workflow = await getWorkflowByWorkspace(workflowId, workspaceId);
if (!workflow) {
return null;
}
if (workflow.status !== "draft" && workflow.status !== "disabled") {
throw new Error("Only draft or disabled workflows can be enabled.");
}
validateDefinition(workflow.definition);
return await prisma.workflow.update({
where: {
id_workspaceId: {
id: workflowId,
workspaceId,
},
},
data: {
status: "enabled",
},
});
};
export const disableWorkflow = async (workflowId: string, workspaceId: string) => {
const workflow = await getWorkflowByWorkspace(workflowId, workspaceId);
if (!workflow) {
return null;
}
if (workflow.status !== "enabled") {
throw new Error("Only enabled workflows can be disabled.");
}
return await prisma.workflow.update({
where: {
id_workspaceId: {
id: workflowId,
workspaceId,
},
},
data: {
status: "disabled",
},
});
};
export const listWorkflowRuns = async ({
workflowId,
workspaceId,
status,
limit,
cursor,
}: TWorkflowRunListParams) => {
const take = getPageLimit(limit);
const rows = await prisma.workflowRun.findMany({
where: {
workflowId,
workspaceId,
...(status ? { status } : {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: take + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const nextCursor = rows.length > take ? rows[take]?.id : null;
return {
runs: rows.slice(0, take),
nextCursor,
};
};
export const listWorkspaceWorkflowRuns = async ({
workspaceId,
status,
limit,
cursor,
}: TWorkspaceWorkflowRunListParams) => {
const take = getPageLimit(limit);
const rows = await prisma.workflowRun.findMany({
where: {
workspaceId,
...(status ? { status } : {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: take + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
include: {
workflow: true,
},
});
const nextCursor = rows.length > take ? rows[take]?.id : null;
return {
runs: rows.slice(0, take),
nextCursor,
};
};
export const getWorkflowRun = async (workflowId: string, workspaceId: string, runId: string) => {
return await prisma.workflowRun.findFirst({
where: {
id: runId,
workflowId,
workspaceId,
},
include: {
workflow: true,
},
});
};
export const enqueueResponseCompletedWorkflowRuns = async ({
workspaceId,
surveyId,
response,
}: {
workspaceId: string;
surveyId: string;
response: TResponsePipelineJobData["response"];
}) => {
const triggerPayload: TWorkflowTriggerPayload = ZWorkflowTriggerPayload.parse({
event: RESPONSE_COMPLETED_TRIGGER_EVENT,
workspaceId,
surveyId,
response,
});
const workflows = await prisma.workflow.findMany({
where: {
workspaceId,
status: "enabled",
},
});
const matchingWorkflows = workflows.filter((workflow) => {
const parsedDefinition = ZWorkflowDefinition.safeParse(workflow.definition);
if (!parsedDefinition.success) {
return false;
}
const trigger = parsedDefinition.data.trigger.config;
return (
trigger.type === RESPONSE_COMPLETED_TRIGGER_EVENT &&
(!trigger.surveyId || trigger.surveyId === surveyId)
);
});
const runs = [];
for (const workflow of matchingWorkflows) {
const run = await prisma.workflowRun.create({
data: {
workflowId: workflow.id,
workspaceId,
status: "queued",
triggerEvent: RESPONSE_COMPLETED_TRIGGER_EVENT,
surveyId,
responseId: response.id,
triggerPayload: toJsonInput(triggerPayload),
data: toJsonInput({ triggerPayload, steps: [], logs: [] }),
},
});
await enqueueWorkflowRunJob({
workflowRunId: run.id,
workflowId: workflow.id,
workspaceId,
});
runs.push(run);
}
return runs;
};
export const enqueueResponseCompletedWorkflowRunsSafely = async ({
workspaceId,
surveyId,
response,
logContext,
}: {
workspaceId: string;
surveyId: string;
response: TResponsePipelineJobData["response"];
logContext?: Record<string, unknown>;
}): Promise<void> => {
try {
await enqueueResponseCompletedWorkflowRuns({ workspaceId, surveyId, response });
} catch (error) {
logger.error(
{
...logContext,
err: error,
},
"Response Completed workflow enqueue failed"
);
}
};
export const processWorkflowRun = async ({
workflowRunId,
workflowId,
workspaceId,
}: {
workflowRunId: string;
workflowId: string;
workspaceId: string;
}) => {
const run = await prisma.workflowRun.findFirst({
where: {
id: workflowRunId,
workflowId,
workspaceId,
},
include: {
workflow: true,
},
});
if (!run) {
throw new Error(`Workflow run ${workflowRunId} was not found`);
}
await prisma.workflowRun.updateMany({
where: {
id: workflowRunId,
workflowId,
workspaceId,
},
data: {
status: "running",
startedAt: new Date(),
error: null,
},
});
const result = executeWorkflowDefinition(run.workflow.definition, run.triggerPayload);
const runData = parseRunData(run.data);
const finishedAt = new Date();
await prisma.workflowRun.updateMany({
where: {
id: workflowRunId,
workflowId,
workspaceId,
},
data: {
status: result.status === "completed" ? "completed" : "failed",
finishedAt,
error: result.error,
data: toJsonInput({
...runData,
triggerPayload: run.triggerPayload,
steps: result.steps,
finalOutput: result.finalOutput,
}),
},
});
if (result.status === "failed") {
throw new Error(result.error ?? `Workflow run ${workflowRunId} failed`);
}
};
export const parseWorkflowStatus = (status: unknown): TWorkflowStatus | undefined => {
if (status === undefined || status === null || status === "") {
return undefined;
}
return ZWorkflowStatus.parse(status);
};
@@ -1,66 +0,0 @@
import { describe, expect, test } from "vitest";
import { ZWorkflowDefinition } from "@formbricks/types/workflows";
import { createDefaultWorkflowDefinition } from "./default-workflow";
describe("workflow schemas", () => {
test("accepts the PoC default workflow definition", () => {
const definition = createDefaultWorkflowDefinition();
expect(ZWorkflowDefinition.safeParse(definition).success).toBe(true);
});
test("rejects deferred compute actions in PoC workflow JSON", () => {
const definition = {
...createDefaultWorkflowDefinition(),
nodes: [
{
id: "compute-1",
type: "action",
actionType: "compute",
config: {},
},
],
};
expect(ZWorkflowDefinition.safeParse(definition).success).toBe(false);
});
test("rejects edges pointing to unknown nodes", () => {
const definition = {
...createDefaultWorkflowDefinition(),
edges: [
{
id: "bad-edge",
source: "trigger-response-completed",
target: "missing-node",
branch: "next",
},
],
};
const result = ZWorkflowDefinition.safeParse(definition);
expect(result.success).toBe(false);
if (result.success) {
throw new Error("Expected workflow definition to be invalid");
}
expect(result.error.issues.some((issue) => issue.message.includes("Unknown edge target"))).toBe(true);
});
test("requires explicit then and else paths for If/Else nodes", () => {
const definition = {
...createDefaultWorkflowDefinition(),
edges: createDefaultWorkflowDefinition().edges.filter((edge) => edge.branch !== "else"),
};
const result = ZWorkflowDefinition.safeParse(definition);
expect(result.success).toBe(false);
if (result.success) {
throw new Error("Expected workflow definition to be invalid");
}
expect(
result.error.issues.some((issue) => issue.message.includes("must have exactly one else edge"))
).toBe(true);
});
});
-175
View File
@@ -1,175 +0,0 @@
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
const SkeletonBlock = ({ className }: Readonly<{ className: string }>) => (
<div className={`animate-pulse rounded-md bg-slate-200 ${className}`} />
);
const WorkflowLoadingHeader = ({
cta = false,
tabs = false,
}: Readonly<{ cta?: boolean; tabs?: boolean }>) => {
return (
<div className="border-b border-slate-200">
<div className="flex items-center justify-between gap-4 pb-4">
<SkeletonBlock className="h-9 w-56" />
{cta ? <SkeletonBlock className="h-9 w-36" /> : null}
</div>
{tabs ? (
<div className="flex animate-pulse gap-6 pb-3">
<div className="h-4 w-20 rounded bg-slate-200" />
<div className="h-4 w-16 rounded bg-slate-200" />
</div>
) : null}
</div>
);
};
const WorkflowRunsTableSkeleton = ({
showWorkflowColumn = false,
}: Readonly<{ showWorkflowColumn?: boolean }>) => {
const runColumnSpan = showWorkflowColumn ? "col-span-2" : "col-span-3";
return (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
<div className="grid grid-cols-12 gap-4 border-b border-slate-200 bg-slate-50 px-4 py-3">
<SkeletonBlock className={`${runColumnSpan} h-4`} />
{showWorkflowColumn ? <SkeletonBlock className="col-span-2 h-4" /> : null}
<SkeletonBlock className="col-span-2 h-4" />
<SkeletonBlock className="col-span-3 h-4" />
<SkeletonBlock className="col-span-2 h-4" />
<SkeletonBlock className="col-span-1 h-4" />
</div>
{[0, 1, 2, 3].map((row) => (
<div
key={row}
className="grid grid-cols-12 gap-4 border-b border-slate-100 px-4 py-4 last:border-b-0">
<SkeletonBlock className={`${runColumnSpan} h-4`} />
{showWorkflowColumn ? <SkeletonBlock className="col-span-2 h-4" /> : null}
<SkeletonBlock className="col-span-2 h-6 rounded-full" />
<SkeletonBlock className="col-span-3 h-4" />
<SkeletonBlock className="col-span-2 h-4" />
<SkeletonBlock className="col-span-1 h-4" />
</div>
))}
</div>
);
};
export const WorkflowsListLoading = () => {
return (
<PageContentWrapper>
<WorkflowLoadingHeader cta />
<div className="flex-col space-y-3">
<div className="mt-6 grid w-full grid-cols-10 place-items-center gap-3 px-6 pr-8">
<SkeletonBlock className="col-span-3 h-4 place-self-start" />
<SkeletonBlock className="col-span-2 h-4" />
<SkeletonBlock className="col-span-2 h-4" />
<SkeletonBlock className="col-span-1 h-4" />
<SkeletonBlock className="col-span-2 h-4" />
</div>
{[0, 1, 2, 3].map((row) => (
<div
key={row}
className="grid w-full grid-cols-10 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm">
<div className="col-span-3 flex w-full flex-col gap-2 justify-self-start">
<SkeletonBlock className="h-4 w-44" />
<SkeletonBlock className="h-3 w-64" />
</div>
<SkeletonBlock className="col-span-2 h-6 w-20 rounded-full" />
<SkeletonBlock className="col-span-2 h-4 w-24" />
<SkeletonBlock className="col-span-1 h-4 w-16" />
<SkeletonBlock className="col-span-2 h-4 w-32" />
</div>
))}
</div>
</PageContentWrapper>
);
};
export const WorkspaceWorkflowRunsLoading = () => {
return (
<PageContentWrapper>
<WorkflowLoadingHeader />
<WorkflowRunsTableSkeleton showWorkflowColumn />
</PageContentWrapper>
);
};
export const WorkflowRunsLoading = () => {
return (
<PageContentWrapper>
<WorkflowLoadingHeader tabs />
<WorkflowRunsTableSkeleton />
</PageContentWrapper>
);
};
export const WorkflowBuilderLoading = () => {
return (
<PageContentWrapper className="space-y-4">
<WorkflowLoadingHeader cta tabs />
<div className="grid max-w-3xl gap-4 md:grid-cols-2">
<div className="space-y-2">
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-10 w-full" />
</div>
<div className="space-y-2">
<SkeletonBlock className="h-4 w-40" />
<SkeletonBlock className="h-10 w-full" />
</div>
</div>
<div className="relative h-[680px] overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
<div className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-lg border border-slate-200 bg-white/95 p-2 shadow-card-sm">
<SkeletonBlock className="h-8 w-36" />
<SkeletonBlock className="h-8 w-28" />
</div>
<div className="absolute left-1/2 top-24 h-32 w-72 -translate-x-1/2 rounded-lg border border-slate-200 bg-white p-3 shadow-card-sm">
<SkeletonBlock className="h-9 w-full" />
<SkeletonBlock className="mt-4 h-4 w-56" />
<SkeletonBlock className="mt-2 h-4 w-48" />
</div>
<div className="absolute left-1/2 top-72 h-32 w-72 -translate-x-1/2 rounded-lg border border-slate-200 bg-white p-3 shadow-card-sm">
<SkeletonBlock className="h-9 w-full" />
<SkeletonBlock className="mt-4 h-4 w-48" />
<SkeletonBlock className="mt-2 h-4 w-40" />
</div>
<div className="absolute bottom-4 right-4 top-4 w-96 rounded-lg border border-slate-200 bg-white p-4 shadow-card-md">
<SkeletonBlock className="h-5 w-40" />
<SkeletonBlock className="mt-6 h-4 w-28" />
<SkeletonBlock className="mt-2 h-10 w-full" />
<SkeletonBlock className="mt-5 h-4 w-32" />
<SkeletonBlock className="mt-2 h-10 w-full" />
</div>
</div>
</PageContentWrapper>
);
};
export const WorkflowRunDetailLoading = () => {
return (
<PageContentWrapper>
<WorkflowLoadingHeader tabs />
<div className="grid grid-cols-12 gap-6">
<section className="col-span-4 space-y-6 rounded-lg border border-slate-200 bg-white p-4">
<SkeletonBlock className="h-4 w-20" />
<SkeletonBlock className="h-6 w-24 rounded-full" />
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-4 w-48" />
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="h-4 w-56" />
</section>
<section className="col-span-8 space-y-4">
<div className="rounded-lg border border-slate-200 bg-white p-4">
<SkeletonBlock className="h-5 w-36" />
<SkeletonBlock className="mt-3 h-48 w-full rounded-lg" />
</div>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="mt-3 h-32 w-full rounded-lg" />
<SkeletonBlock className="mt-3 h-32 w-full rounded-lg" />
</div>
</section>
</div>
</PageContentWrapper>
);
};
@@ -1,804 +0,0 @@
"use client";
import {
Background,
BackgroundVariant,
Controls,
Handle,
type Node,
type NodeProps,
type OnNodesChange,
Position,
ReactFlow,
ReactFlowProvider,
type SnapGrid,
applyNodeChanges,
} from "@xyflow/react";
import { Provider, useAtomValue, useSetAtom } from "jotai";
import {
GitBranchIcon,
MailIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PowerIcon,
PowerOffIcon,
RefreshCcwIcon,
WebhookIcon,
ZapIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import type { TWorkflowDefinition, TWorkflowNode } from "@formbricks/types/workflows";
import { ZWorkflowDefinition } from "@formbricks/types/workflows";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { Switch } from "@/modules/ui/components/switch";
import { WorkflowStatusPill } from "../components/status-badges";
import { WorkflowSecondaryNavigation } from "../components/workflow-secondary-navigation";
import { disableWorkflow, enableWorkflow, getWorkflow, updateWorkflow } from "../lib/api-client";
import { WorkflowBuilderLoading } from "../loading";
import {
type TWorkflowNodeData,
hydrateWorkflowEditorAtom,
isWorkflowConfigDrawerCollapsedAtom,
isWorkflowSnapToCanvasEnabledAtom,
selectedWorkflowNodeIdAtom,
setSelectedWorkflowNodeIdAtom,
setWorkflowAtom,
setWorkflowDefinitionAtom,
setWorkflowDescriptionAtom,
setWorkflowFlowNodesAtom,
setWorkflowNameAtom,
setWorkflowSnapToCanvasEnabledAtom,
toggleWorkflowConfigDrawerAtom,
workflowAtom,
workflowDefinitionAtom,
workflowDescriptionAtom,
workflowFlowNodesAtom,
workflowNameAtom,
} from "../state/editor";
type TTranslate = (key: string, options?: Record<string, unknown>) => string;
type TWorkflowBuilderPageProps = Readonly<{ workspaceId: string; workflowId: string; isReadOnly: boolean }>;
const nodeChipClassNames = {
trigger: "bg-brand-dark text-white",
flow: "bg-green-700 text-white",
action: "bg-red-700 text-white",
};
const nodeIcons = {
trigger: ZapIcon,
ifElse: GitBranchIcon,
email: MailIcon,
webhook: WebhookIcon,
};
const WorkflowCanvasNode = memo(({ data, selected }: NodeProps<Node<TWorkflowNodeData>>) => {
const Icon = nodeIcons[data.icon];
return (
<div
className={[
"w-72 rounded-lg border border-slate-200 bg-white shadow-card-sm transition-shadow hover:shadow-card-md",
selected ? "ring-2 ring-brand-dark ring-offset-2 ring-offset-slate-50" : "",
].join(" ")}>
<Handle
type="target"
position={Position.Top}
className="!size-2 !border-2 !border-slate-300 !bg-white"
/>
<div className="flex h-9 items-center gap-2 border-b border-slate-200 px-3">
<span
className={`flex size-6 items-center justify-center rounded-md ${nodeChipClassNames[data.category]}`}>
<Icon className="size-4" strokeWidth={1.5} />
</span>
<span className="truncate text-sm font-semibold text-slate-800">{data.title}</span>
</div>
<div className="line-clamp-3 p-3 text-sm text-slate-600">{data.summary}</div>
<Handle
type="source"
position={Position.Bottom}
className="!size-2 !border-2 !border-slate-300 !bg-white"
/>
</div>
);
});
WorkflowCanvasNode.displayName = "WorkflowCanvasNode";
const nodeTypes = {
workflow: WorkflowCanvasNode,
};
const workflowCanvasSnapGrid: SnapGrid = [20, 20];
const workflowCanvasNodeSpacing = {
x: 360,
y: 180,
};
const workflowCanvasStartPosition = {
x: 220,
y: 80,
};
const getNodeTitle = (node: TWorkflowDefinition["trigger"] | TWorkflowNode, t: TTranslate): string => {
if (node.type === "trigger") {
return t("workspace.workflows.response_completed");
}
if (node.type === "ifElse") {
return t("workspace.workflows.if_else");
}
return node.actionType === "sendEmailPreview"
? t("workspace.workflows.send_email_preview")
: t("workspace.workflows.send_webhook_preview");
};
const getNodeSummary = (node: TWorkflowDefinition["trigger"] | TWorkflowNode, t: TTranslate): string => {
if (node.type === "trigger") {
return node.config.surveyId
? t("workspace.workflows.survey_trigger_summary", { surveyId: node.config.surveyId })
: t("workspace.workflows.any_completed_response");
}
if (node.type === "ifElse") {
return t("workspace.workflows.if_else_summary");
}
if (node.actionType === "sendEmailPreview") {
return t("workspace.workflows.email_preview_summary", { to: node.config.to });
}
return t("workspace.workflows.webhook_preview_summary", { url: node.config.url });
};
const workflowDefinitionToFlowNodes = (
definition: TWorkflowDefinition,
t: TTranslate
): Array<Node<TWorkflowNodeData>> => {
const allNodes = [definition.trigger, ...definition.nodes];
return allNodes.map((node, index) => {
const isTrigger = node.type === "trigger";
const position = node.ui?.position ?? { x: 120, y: 80 + index * 160 };
const category = isTrigger ? "trigger" : node.type === "ifElse" ? "flow" : "action";
const icon = isTrigger
? "trigger"
: node.type === "ifElse"
? "ifElse"
: node.actionType === "sendEmailPreview"
? "email"
: "webhook";
return {
id: node.id,
type: "workflow",
position,
data: {
category,
title: getNodeTitle(node, t),
summary: getNodeSummary(node, t),
icon,
},
};
});
};
const workflowDefinitionToFlowEdges = (definition: TWorkflowDefinition) =>
definition.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.branch === "next" ? undefined : edge.branch,
type: "smoothstep",
animated: edge.branch === "then",
}));
const updateNodePosition = (
definition: TWorkflowDefinition,
nodeId: string,
position: { x: number; y: number }
): TWorkflowDefinition => {
if (definition.trigger.id === nodeId) {
return {
...definition,
trigger: {
...definition.trigger,
ui: {
...definition.trigger.ui,
position,
},
},
};
}
return {
...definition,
nodes: definition.nodes.map((node) =>
node.id === nodeId
? {
...node,
ui: {
...node.ui,
position,
},
}
: node
),
};
};
const snapWorkflowNodePosition = (position: { x: number; y: number }) => ({
x: Math.round(position.x / workflowCanvasSnapGrid[0]) * workflowCanvasSnapGrid[0],
y: Math.round(position.y / workflowCanvasSnapGrid[1]) * workflowCanvasSnapGrid[1],
});
const reorganizeWorkflowDefinition = (definition: TWorkflowDefinition): TWorkflowDefinition => {
const nodesById = new Map([definition.trigger, ...definition.nodes].map((node) => [node.id, node]));
const originalNodeOrder = [definition.trigger.id, ...definition.nodes.map((node) => node.id)];
const edgesBySource = new Map<string, string[]>();
for (const edge of definition.edges) {
edgesBySource.set(edge.source, [...(edgesBySource.get(edge.source) ?? []), edge.target]);
}
const ranks = new Map<string, number>([[definition.trigger.id, 0]]);
const queue = [definition.trigger.id];
for (const nodeId of queue) {
const nextRank = (ranks.get(nodeId) ?? 0) + 1;
for (const targetId of edgesBySource.get(nodeId) ?? []) {
if (!nodesById.has(targetId) || ranks.has(targetId)) continue;
ranks.set(targetId, nextRank);
queue.push(targetId);
}
}
for (const nodeId of originalNodeOrder) {
if (!ranks.has(nodeId)) {
ranks.set(nodeId, ranks.size);
}
}
const nodeIdsByRank = new Map<number, string[]>();
for (const nodeId of originalNodeOrder) {
const rank = ranks.get(nodeId) ?? 0;
nodeIdsByRank.set(rank, [...(nodeIdsByRank.get(rank) ?? []), nodeId]);
}
const positionsByNodeId = new Map<string, { x: number; y: number }>();
for (const [rank, nodeIds] of nodeIdsByRank) {
const rowOffset = ((nodeIds.length - 1) * workflowCanvasNodeSpacing.x) / 2;
nodeIds.forEach((nodeId, index) => {
positionsByNodeId.set(
nodeId,
snapWorkflowNodePosition({
x: workflowCanvasStartPosition.x + index * workflowCanvasNodeSpacing.x - rowOffset,
y: workflowCanvasStartPosition.y + rank * workflowCanvasNodeSpacing.y,
})
);
});
}
return {
...definition,
trigger: {
...definition.trigger,
ui: {
...definition.trigger.ui,
position: positionsByNodeId.get(definition.trigger.id) ?? definition.trigger.ui?.position,
},
},
nodes: definition.nodes.map((node) => ({
...node,
ui: {
...node.ui,
position: positionsByNodeId.get(node.id) ?? node.ui?.position,
},
})),
};
};
const NodeConfigDrawer = ({
definition,
isEditable,
isCollapsed,
selectedNodeId,
onChange,
onToggleCollapsed,
}: Readonly<{
definition: TWorkflowDefinition;
isEditable: boolean;
isCollapsed: boolean;
selectedNodeId: string | null;
onChange: (definition: TWorkflowDefinition) => void;
onToggleCollapsed: () => void;
}>) => {
const { t } = useTranslation();
const selectedNode =
selectedNodeId === definition.trigger.id
? definition.trigger
: definition.nodes.find((node) => node.id === selectedNodeId);
if (isCollapsed) {
return (
<aside className="absolute bottom-4 right-4 top-4 flex w-14 justify-center rounded-lg border border-slate-200 bg-white p-2 shadow-card-md">
<Button
aria-label={t("workspace.workflows.expand_node_config")}
size="icon"
title={t("workspace.workflows.expand_node_config")}
type="button"
variant="ghost"
onClick={onToggleCollapsed}>
<PanelRightOpenIcon />
</Button>
</aside>
);
}
if (!selectedNode) {
return (
<aside className="absolute bottom-4 right-4 top-4 w-96 rounded-lg border border-slate-200 bg-white p-4 shadow-card-md">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-slate-800">{t("workspace.workflows.node_config")}</h2>
<Button
aria-label={t("workspace.workflows.collapse_node_config")}
size="icon"
title={t("workspace.workflows.collapse_node_config")}
type="button"
variant="ghost"
onClick={onToggleCollapsed}>
<PanelRightCloseIcon />
</Button>
</div>
<p className="mt-2 text-sm text-slate-500">{t("workspace.workflows.select_node")}</p>
</aside>
);
}
const updateNode = (node: typeof selectedNode) => {
if (node.type === "trigger") {
onChange({ ...definition, trigger: node });
return;
}
onChange({
...definition,
nodes: definition.nodes.map((existingNode) => (existingNode.id === node.id ? node : existingNode)),
});
};
const firstIfElseCondition =
selectedNode.type === "ifElse" ? selectedNode.config.condition.conditions[0] : undefined;
const selectedCondition =
firstIfElseCondition && !("connector" in firstIfElseCondition) ? firstIfElseCondition : null;
return (
<aside className="absolute bottom-4 right-4 top-4 w-96 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4 shadow-card-md">
<div className="flex items-start justify-between gap-3 border-b border-slate-200 pb-3">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-slate-800">{getNodeTitle(selectedNode, t)}</h2>
<p className="mt-1 truncate text-xs text-slate-500">{selectedNode.id}</p>
</div>
<Button
aria-label={t("workspace.workflows.collapse_node_config")}
size="icon"
title={t("workspace.workflows.collapse_node_config")}
type="button"
variant="ghost"
onClick={onToggleCollapsed}>
<PanelRightCloseIcon />
</Button>
</div>
<div className="mt-4 space-y-4">
{selectedNode.type === "trigger" ? (
<label className="block">
<span className="text-sm font-medium text-slate-700">{t("common.survey_id")}</span>
<Input
value={selectedNode.config.surveyId ?? ""}
disabled={!isEditable}
placeholder={t("workspace.workflows.any_survey")}
onChange={(event) =>
updateNode({
...selectedNode,
config: {
...selectedNode.config,
surveyId: event.target.value.trim() || null,
},
})
}
/>
</label>
) : null}
{selectedNode.type === "ifElse" ? (
<>
<label className="block">
<span className="text-sm font-medium text-slate-700">
{t("workspace.workflows.condition_left_path")}
</span>
<Input
value={selectedCondition?.left.path ?? ""}
disabled={!isEditable}
onChange={(event) => {
if (!selectedCondition) return;
updateNode({
...selectedNode,
config: {
condition: {
...selectedNode.config.condition,
conditions: [
{
...selectedCondition,
left: { type: "ref", path: event.target.value },
},
],
},
},
});
}}
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">
{t("workspace.workflows.condition_right_value")}
</span>
<Input
value={String(selectedCondition?.right ?? "")}
disabled={!isEditable}
onChange={(event) => {
if (!selectedCondition) return;
updateNode({
...selectedNode,
config: {
condition: {
...selectedNode.config.condition,
conditions: [
{
...selectedCondition,
right:
event.target.value === "true"
? true
: event.target.value === "false"
? false
: event.target.value,
},
],
},
},
});
}}
/>
</label>
</>
) : null}
{selectedNode.type === "action" && selectedNode.actionType === "sendEmailPreview" ? (
<>
<label className="block">
<span className="text-sm font-medium text-slate-700">{t("workspace.workflows.email_to")}</span>
<Input
value={selectedNode.config.to}
disabled={!isEditable}
onChange={(event) =>
updateNode({
...selectedNode,
config: { ...selectedNode.config, to: event.target.value },
})
}
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">
{t("workspace.workflows.email_subject")}
</span>
<Input
value={selectedNode.config.subject}
disabled={!isEditable}
onChange={(event) =>
updateNode({
...selectedNode,
config: { ...selectedNode.config, subject: event.target.value },
})
}
/>
</label>
</>
) : null}
{selectedNode.type === "action" && selectedNode.actionType === "sendWebhookPreview" ? (
<label className="block">
<span className="text-sm font-medium text-slate-700">{t("workspace.workflows.webhook_url")}</span>
<Input
value={selectedNode.config.url}
disabled={!isEditable}
onChange={(event) =>
updateNode({
...selectedNode,
config: { ...selectedNode.config, url: event.target.value },
})
}
/>
<p className="mt-2 text-xs text-slate-500">{t("workspace.workflows.webhook_preview_note")}</p>
</label>
) : null}
</div>
</aside>
);
};
const WorkflowBuilderPageContent = ({ workspaceId, workflowId, isReadOnly }: TWorkflowBuilderPageProps) => {
const { t } = useTranslation();
const router = useRouter();
const workflow = useAtomValue(workflowAtom);
const workflowName = useAtomValue(workflowNameAtom);
const workflowDescription = useAtomValue(workflowDescriptionAtom);
const definition = useAtomValue(workflowDefinitionAtom);
const flowNodes = useAtomValue(workflowFlowNodesAtom);
const selectedNodeId = useAtomValue(selectedWorkflowNodeIdAtom);
const isConfigDrawerCollapsed = useAtomValue(isWorkflowConfigDrawerCollapsedAtom);
const isSnapToCanvasEnabled = useAtomValue(isWorkflowSnapToCanvasEnabledAtom);
const hydrateWorkflowEditor = useSetAtom(hydrateWorkflowEditorAtom);
const setWorkflow = useSetAtom(setWorkflowAtom);
const setWorkflowName = useSetAtom(setWorkflowNameAtom);
const setWorkflowDescription = useSetAtom(setWorkflowDescriptionAtom);
const setDefinition = useSetAtom(setWorkflowDefinitionAtom);
const setFlowNodes = useSetAtom(setWorkflowFlowNodesAtom);
const setSelectedNodeId = useSetAtom(setSelectedWorkflowNodeIdAtom);
const setSnapToCanvasEnabled = useSetAtom(setWorkflowSnapToCanvasEnabledAtom);
const toggleConfigDrawer = useSetAtom(toggleWorkflowConfigDrawerAtom);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const canEdit = Boolean(workflow && !isReadOnly && workflow.status !== "enabled");
useEffect(() => {
const loadWorkflow = async () => {
setIsLoading(true);
try {
const loadedWorkflow = await getWorkflow(workflowId);
hydrateWorkflowEditor({
workflow: loadedWorkflow,
flowNodes: workflowDefinitionToFlowNodes(loadedWorkflow.definition, t),
});
} catch (error) {
toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.load_failed")));
} finally {
setIsLoading(false);
}
};
void loadWorkflow();
}, [hydrateWorkflowEditor, workflowId, t]);
const derivedFlowNodes = useMemo(
() => (definition ? workflowDefinitionToFlowNodes(definition, t) : []),
[definition, t]
);
const flowEdges = useMemo(
() => (definition ? workflowDefinitionToFlowEdges(definition) : []),
[definition]
);
useEffect(() => {
setFlowNodes((currentNodes) => {
const currentNodesById = new Map(currentNodes.map((node) => [node.id, node]));
return derivedFlowNodes.map((node) => ({
...node,
selected: currentNodesById.get(node.id)?.selected ?? node.selected,
}));
});
}, [derivedFlowNodes, setFlowNodes]);
const handleNodesChange: OnNodesChange<Node<TWorkflowNodeData>> = useCallback(
(changes) => {
setFlowNodes((currentNodes) => applyNodeChanges(changes, currentNodes));
},
[setFlowNodes]
);
const handleNodeDragStop = useCallback(
(node: Node<TWorkflowNodeData>) => {
if (!canEdit) return;
const position = isSnapToCanvasEnabled ? snapWorkflowNodePosition(node.position) : node.position;
setDefinition((currentDefinition) =>
currentDefinition ? updateNodePosition(currentDefinition, node.id, position) : currentDefinition
);
},
[canEdit, isSnapToCanvasEnabled, setDefinition]
);
const handleReorganizeCanvas = () => {
if (!canEdit) return;
setDefinition((currentDefinition) =>
currentDefinition ? reorganizeWorkflowDefinition(currentDefinition) : currentDefinition
);
};
const handleSave = async () => {
if (!workflow || !definition) return;
const trimmedWorkflowName = workflowName.trim();
if (!trimmedWorkflowName) {
toast.error(t("common.something_went_wrong"));
return;
}
const parsedDefinition = ZWorkflowDefinition.safeParse(definition);
if (!parsedDefinition.success) {
toast.error(parsedDefinition.error.issues[0]?.message ?? t("workspace.workflows.validation_failed"));
return;
}
setIsSaving(true);
try {
const savedWorkflow = await updateWorkflow({
workflowId: workflow.id,
name: trimmedWorkflowName,
description: workflowDescription.trim() || null,
definition: parsedDefinition.data,
});
setWorkflow(savedWorkflow);
setWorkflowName(savedWorkflow.name);
setWorkflowDescription(savedWorkflow.description ?? "");
setDefinition(savedWorkflow.definition);
toast.success(t("workspace.workflows.saved"));
} catch (error) {
toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.save_failed")));
} finally {
setIsSaving(false);
}
};
const handleLifecycleTransition = async () => {
if (!workflow) return;
setIsTransitioning(true);
try {
const transitionedWorkflow =
workflow.status === "enabled"
? await disableWorkflow(workflow.id)
: await enableWorkflow(workflow.id);
setWorkflow(transitionedWorkflow);
setWorkflowName(transitionedWorkflow.name);
setWorkflowDescription(transitionedWorkflow.description ?? "");
setDefinition(transitionedWorkflow.definition);
toast.success(
transitionedWorkflow.status === "enabled"
? t("workspace.workflows.enabled")
: t("workspace.workflows.disabled")
);
} catch (error) {
toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.lifecycle_failed")));
} finally {
setIsTransitioning(false);
}
};
if (isLoading || !workflow || !definition) {
return <WorkflowBuilderLoading />;
}
return (
<PageContentWrapper className="space-y-4">
<PageHeader
pageTitle={workflow.name}
cta={
<div className="flex items-center gap-2">
<WorkflowStatusPill status={workflow.status} />
{canEdit ? (
<Button size="sm" variant="secondary" onClick={handleSave} loading={isSaving}>
{t("common.save")}
</Button>
) : null}
{!isReadOnly ? (
<Button size="sm" onClick={handleLifecycleTransition} loading={isTransitioning}>
{workflow.status === "enabled"
? t("workspace.workflows.disable")
: t("workspace.workflows.enable")}
{workflow.status === "enabled" ? <PowerOffIcon /> : <PowerIcon />}
</Button>
) : null}
</div>
}>
<WorkflowSecondaryNavigation workspaceId={workspaceId} workflowId={workflow.id} activeId="builder" />
</PageHeader>
{canEdit ? (
<div className="grid max-w-3xl gap-4 md:grid-cols-2">
<label className="block">
<span className="text-sm font-medium text-slate-700">
{t("workspace.workflows.workflow_name")}
</span>
<Input value={workflowName} onChange={(event) => setWorkflowName(event.target.value)} />
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">
{t("workspace.workflows.workflow_description_optional")}
</span>
<Input
value={workflowDescription}
placeholder={t("workspace.workflows.workflow_description_placeholder")}
onChange={(event) => setWorkflowDescription(event.target.value)}
/>
</label>
</div>
) : workflow.description ? (
<p className="max-w-3xl text-sm text-slate-500">{workflow.description}</p>
) : null}
<div className="relative h-[680px] overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
<div className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-lg border border-slate-200 bg-white/95 p-2 shadow-card-sm">
<label className="flex items-center gap-2 whitespace-nowrap px-2 text-sm font-medium text-slate-700">
<Switch
checked={isSnapToCanvasEnabled}
disabled={!canEdit}
onCheckedChange={setSnapToCanvasEnabled}
/>
{t("workspace.workflows.snap_to_canvas")}
</label>
<Button size="sm" variant="secondary" disabled={!canEdit} onClick={handleReorganizeCanvas}>
{t("workspace.workflows.reorganize")}
<RefreshCcwIcon />
</Button>
</div>
<ReactFlowProvider>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
nodeTypes={nodeTypes}
onNodesChange={handleNodesChange}
onNodeDragStop={(_event, node) => handleNodeDragStop(node)}
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
className="bg-slate-50"
fitView
nodesDraggable={canEdit}
nodesConnectable={false}
snapGrid={workflowCanvasSnapGrid}
snapToGrid={isSnapToCanvasEnabled}
elementsSelectable>
<Background color="#94a3b8" gap={20} size={1.4} variant={BackgroundVariant.Dots} />
<Controls />
</ReactFlow>
</ReactFlowProvider>
<NodeConfigDrawer
definition={definition}
isEditable={canEdit}
isCollapsed={isConfigDrawerCollapsed}
selectedNodeId={selectedNodeId}
onChange={(updatedDefinition) => {
if (canEdit) {
setDefinition(updatedDefinition);
}
}}
onToggleCollapsed={toggleConfigDrawer}
/>
</div>
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => router.push(`/workspaces/${workspaceId}/workflows`)}>
{t("workspace.workflows.back_to_workflows")}
</Button>
</div>
</PageContentWrapper>
);
};
export const WorkflowBuilderPage = (props: TWorkflowBuilderPageProps) => (
<Provider>
<WorkflowBuilderPageContent {...props} />
</Provider>
);
@@ -1,133 +0,0 @@
"use client";
import { RefreshCcwIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkflowRunStatusBadge } from "../components/status-badges";
import { WorkflowSecondaryNavigation } from "../components/workflow-secondary-navigation";
import { getWorkflowRun } from "../lib/api-client";
import { WorkflowRunDetailLoading } from "../loading";
import type { TWorkflowRun } from "../types/workflows";
const JsonBlock = ({ value }: Readonly<{ value: unknown }>) => (
<CodeBlock language="json" noMargin>
{JSON.stringify(value, null, 2)}
</CodeBlock>
);
export const WorkflowRunDetailPage = ({
workspaceId,
workflowId,
runId,
}: Readonly<{ workspaceId: string; workflowId: string; runId: string }>) => {
const { t, i18n } = useTranslation();
const [run, setRun] = useState<TWorkflowRun | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRun = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await getWorkflowRun(workflowId, runId);
setRun(result);
} catch (loadError) {
setError(getV3ApiErrorMessage(loadError, t("common.something_went_wrong_please_try_again")));
} finally {
setIsLoading(false);
}
}, [workflowId, runId, t]);
useEffect(() => {
void loadRun();
}, [loadRun]);
if (isLoading) {
return <WorkflowRunDetailLoading />;
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.workflows.run_detail")}>
<WorkflowSecondaryNavigation workspaceId={workspaceId} workflowId={workflowId} activeId="runs" />
</PageHeader>
{error || !run ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-white py-16 text-slate-600">
<p>{error ?? t("common.something_went_wrong_please_try_again")}</p>
<Button size="sm" variant="secondary" onClick={loadRun}>
{t("common.try_again")}
<RefreshCcwIcon />
</Button>
</div>
) : (
<div className="grid grid-cols-12 gap-6">
<section className="col-span-4 space-y-4 rounded-lg border border-slate-200 bg-white p-4">
<div>
<p className="text-xs font-medium uppercase text-slate-500">{t("common.status")}</p>
<div className="mt-2">
<WorkflowRunStatusBadge status={run.status} />
</div>
</div>
<div>
<p className="text-xs font-medium uppercase text-slate-500">{t("common.created_at")}</p>
<p className="mt-1 text-sm text-slate-800">
{formatDateTimeForDisplay(new Date(run.createdAt), i18n.resolvedLanguage)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase text-slate-500">
{t("workspace.workflows.response")}
</p>
<p className="mt-1 break-all text-sm text-slate-800">{run.responseId ?? t("common.none")}</p>
</div>
{run.error ? (
<div>
<p className="text-xs font-medium uppercase text-slate-500">
{t("workspace.workflows.error")}
</p>
<p className="mt-1 text-sm text-red-700">{run.error}</p>
</div>
) : null}
</section>
<section className="col-span-8 space-y-4">
<div className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="text-sm font-semibold text-slate-800">
{t("workspace.workflows.trigger_payload")}
</h2>
<div className="mt-3">
<JsonBlock value={run.triggerPayload} />
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="text-sm font-semibold text-slate-800">
{t("workspace.workflows.step_outputs")}
</h2>
<div className="mt-3 space-y-3">
{run.data.steps.map((step) => (
<div
key={`${step.nodeId}-${step.startedAt}`}
className="rounded-md border border-slate-200 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium text-slate-800">{step.nodeId}</p>
<span className="text-xs text-slate-500">{step.status}</span>
</div>
<JsonBlock value={{ input: step.input, output: step.output, error: step.error }} />
</div>
))}
</div>
</div>
</section>
</div>
)}
</PageContentWrapper>
);
};
@@ -1,69 +0,0 @@
"use client";
import { RefreshCcwIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkflowRunsTable } from "../components/workflow-runs-table";
import { WorkflowSecondaryNavigation } from "../components/workflow-secondary-navigation";
import { listWorkflowRuns } from "../lib/api-client";
import { WorkflowRunsLoading } from "../loading";
import type { TWorkflowRun } from "../types/workflows";
export const WorkflowRunsPage = ({
workspaceId,
workflowId,
}: Readonly<{ workspaceId: string; workflowId: string }>) => {
const { t } = useTranslation();
const [runs, setRuns] = useState<TWorkflowRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRuns = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await listWorkflowRuns({ workflowId });
setRuns(result.data);
} catch (loadError) {
setError(getV3ApiErrorMessage(loadError, t("common.something_went_wrong_please_try_again")));
} finally {
setIsLoading(false);
}
}, [workflowId, t]);
useEffect(() => {
void loadRuns();
}, [loadRuns]);
if (isLoading) {
return <WorkflowRunsLoading />;
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.workflows.runs")}>
<WorkflowSecondaryNavigation workspaceId={workspaceId} workflowId={workflowId} activeId="runs" />
</PageHeader>
{error ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-white py-16 text-slate-600">
<p>{error}</p>
<Button size="sm" variant="secondary" onClick={loadRuns}>
{t("common.try_again")}
<RefreshCcwIcon />
</Button>
</div>
) : runs.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-white py-16 text-center text-sm text-slate-500">
{t("workspace.workflows.no_runs")}
</div>
) : (
<WorkflowRunsTable runs={runs} workspaceId={workspaceId} workflowId={workflowId} />
)}
</PageContentWrapper>
);
};
@@ -1,296 +0,0 @@
"use client";
import {
CopyIcon,
GitBranchIcon,
MoreVerticalIcon,
PlusIcon,
RefreshCcwIcon,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { timeSince } from "@/lib/time";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { CreateWorkflowDialog } from "../components/create-workflow-dialog";
import { WorkflowStatusPill } from "../components/status-badges";
import { createWorkflow, deleteWorkflow, listWorkflows } from "../lib/api-client";
import { createDefaultWorkflowDefinition } from "../lib/default-workflow";
import { WorkflowsListLoading } from "../loading";
import type { TWorkflow } from "../types/workflows";
const formatWorkflowLastRunAt = (workflow: TWorkflow, locale: string, emptyLabel: string): string => {
const lastRunAt =
workflow.lastRun?.finishedAt ?? workflow.lastRun?.startedAt ?? workflow.lastRun?.createdAt;
return lastRunAt ? formatDateTimeForDisplay(new Date(lastRunAt), locale) : emptyLabel;
};
type TWorkflowRowMenuProps = Readonly<{
workflow: TWorkflow;
workspaceId: string;
isReadOnly: boolean;
onDelete: (workflowId: string) => Promise<void>;
}>;
const WorkflowRowMenu = ({ workflow, workspaceId, isReadOnly, onDelete }: TWorkflowRowMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const editHref = `/workspaces/${workspaceId}/workflows/${workflow.id}`;
const handleDeleteWorkflow = async () => {
setIsDeleting(true);
try {
await onDelete(workflow.id);
setDeleteDialogOpen(false);
} catch (error) {
toast.error(getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again")));
} finally {
setIsDeleting(false);
}
};
return (
<div id={`${workflow.name.toLowerCase().split(" ").join("-")}-workflow-actions`}>
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild>
<button
type="button"
aria-label={t("common.open_options")}
className="rounded-lg border bg-white p-2 hover:bg-slate-50">
<span className="sr-only">{t("common.open_options")}</span>
<MoreVerticalIcon className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link className="flex w-full items-center" href={editHref}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled>
<button type="button" className="flex w-full cursor-not-allowed items-center" disabled>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem disabled={isReadOnly}>
<button
type="button"
className={cn("flex w-full items-center", isReadOnly && "cursor-not-allowed opacity-50")}
disabled={isReadOnly}
onClick={(event) => {
event.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{!isReadOnly ? (
<DeleteDialog
deleteWhat={workflow.name}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteWorkflow}
isDeleting={isDeleting}
/>
) : null}
</div>
);
};
export const WorkflowsListPage = ({
workspaceId,
isReadOnly,
}: Readonly<{ workspaceId: string; isReadOnly: boolean }>) => {
const { t, i18n } = useTranslation();
const router = useRouter();
const locale = i18n.resolvedLanguage ?? "en-US";
const [workflows, setWorkflows] = useState<TWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [workflowName, setWorkflowName] = useState("");
const [workflowDescription, setWorkflowDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const loadWorkflows = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await listWorkflows({ workspaceId });
setWorkflows(result.data);
} catch (loadError) {
setError(getV3ApiErrorMessage(loadError, t("common.something_went_wrong_please_try_again")));
} finally {
setIsLoading(false);
}
}, [workspaceId, t]);
useEffect(() => {
void loadWorkflows();
}, [loadWorkflows]);
const handleCreateDialogOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
if (!open) {
setWorkflowName("");
setWorkflowDescription("");
}
};
const handleCreateWorkflow = async () => {
const trimmedWorkflowName = workflowName.trim();
if (!trimmedWorkflowName) {
toast.error(t("workspace.workflows.please_enter_name"));
return;
}
setIsCreating(true);
try {
const workflow = await createWorkflow({
workspaceId,
name: trimmedWorkflowName,
description: workflowDescription.trim() || null,
definition: createDefaultWorkflowDefinition(),
});
toast.success(t("workspace.workflows.create_success"));
handleCreateDialogOpenChange(false);
router.push(`/workspaces/${workspaceId}/workflows/${workflow.id}`);
} catch (createError) {
toast.error(getV3ApiErrorMessage(createError, t("workspace.workflows.create_failed")));
} finally {
setIsCreating(false);
}
};
const handleDeleteWorkflow = async (workflowId: string) => {
await deleteWorkflow(workflowId);
setWorkflows((currentWorkflows) => currentWorkflows.filter((workflow) => workflow.id !== workflowId));
};
const createButton = isReadOnly ? undefined : (
<Button size="sm" onClick={() => handleCreateDialogOpenChange(true)}>
{t("workspace.workflows.create_workflow")}
<PlusIcon />
</Button>
);
if (isLoading) {
return <WorkflowsListLoading />;
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workflows")} cta={createButton} />
{!isReadOnly ? (
<CreateWorkflowDialog
open={isCreateDialogOpen}
onOpenChange={handleCreateDialogOpenChange}
workflowName={workflowName}
workflowDescription={workflowDescription}
onWorkflowNameChange={setWorkflowName}
onWorkflowDescriptionChange={setWorkflowDescription}
onCreate={handleCreateWorkflow}
isCreating={isCreating}
/>
) : null}
{error ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-white py-16 text-slate-600">
<p>{error}</p>
<Button size="sm" variant="secondary" onClick={loadWorkflows}>
{t("common.try_again")}
<RefreshCcwIcon />
</Button>
</div>
) : workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-white py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<GitBranchIcon />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800">{t("workspace.workflows.no_workflows")}</h2>
<p className="mt-1 text-sm text-slate-500">{t("workspace.workflows.no_workflows_description")}</p>
</div>
{createButton}
</div>
) : (
<div className="flex-col space-y-3">
<div className="mt-6 grid w-full grid-cols-10 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-3 place-self-start">{t("common.name")}</div>
<div className="col-span-2">{t("common.status")}</div>
<div className="col-span-2">{t("common.created_at")}</div>
<div className="col-span-1">{t("common.updated_at")}</div>
<div className="col-span-2">{t("workspace.workflows.last_run")}</div>
</div>
{workflows.map((workflow) => (
<div key={workflow.id} className="relative block">
<Link href={`/workspaces/${workspaceId}/workflows/${workflow.id}`} className="block">
<div className="grid w-full grid-cols-10 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out hover:border-slate-400">
<div className="col-span-3 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="min-w-0">
<div className="truncate">{workflow.name}</div>
{workflow.description ? (
<div className="mt-1 truncate text-xs font-normal text-slate-500">
{workflow.description}
</div>
) : null}
</div>
</div>
<div className="col-span-2 flex max-w-full items-center">
<WorkflowStatusPill status={workflow.status} />
</div>
<div className="col-span-2 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(new Date(workflow.createdAt), locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(workflow.updatedAt, locale)}
</div>
<div className="col-span-2 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatWorkflowLastRunAt(workflow, locale, t("common.none"))}
</div>
</div>
</Link>
<div className="absolute right-3 top-3.5">
<WorkflowRowMenu
workflow={workflow}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
onDelete={handleDeleteWorkflow}
/>
</div>
</div>
))}
</div>
)}
</PageContentWrapper>
);
};
@@ -1,63 +0,0 @@
"use client";
import { RefreshCcwIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkflowRunsTable } from "../components/workflow-runs-table";
import { listWorkspaceWorkflowRuns } from "../lib/api-client";
import { WorkspaceWorkflowRunsLoading } from "../loading";
import type { TWorkflowRun } from "../types/workflows";
export const WorkspaceWorkflowRunsPage = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
const { t } = useTranslation();
const [runs, setRuns] = useState<TWorkflowRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRuns = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await listWorkspaceWorkflowRuns({ workspaceId });
setRuns(result.data);
} catch (loadError) {
setError(getV3ApiErrorMessage(loadError, t("common.something_went_wrong_please_try_again")));
} finally {
setIsLoading(false);
}
}, [workspaceId, t]);
useEffect(() => {
void loadRuns();
}, [loadRuns]);
if (isLoading) {
return <WorkspaceWorkflowRunsLoading />;
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.workflows.workflow_runs")} />
{error ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-white py-16 text-slate-600">
<p>{error}</p>
<Button size="sm" variant="secondary" onClick={loadRuns}>
{t("common.try_again")}
<RefreshCcwIcon />
</Button>
</div>
) : runs.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-white py-16 text-center text-sm text-slate-500">
{t("workspace.workflows.no_runs")}
</div>
) : (
<WorkflowRunsTable runs={runs} workspaceId={workspaceId} showWorkflowColumn />
)}
</PageContentWrapper>
);
};
-163
View File
@@ -1,163 +0,0 @@
"use client";
import { type Node } from "@xyflow/react";
import { produce } from "immer";
import { atom } from "jotai";
import type { SetStateAction } from "react";
import type { TWorkflowDefinition } from "@formbricks/types/workflows";
import type { TWorkflow } from "../types/workflows";
export type TWorkflowNodeData = {
category: "trigger" | "flow" | "action";
title: string;
summary: string;
icon: "trigger" | "ifElse" | "email" | "webhook";
};
type TWorkflowEditorState = {
workflow: TWorkflow | null;
workflowName: string;
workflowDescription: string;
definition: TWorkflowDefinition | null;
flowNodes: Array<Node<TWorkflowNodeData>>;
selectedNodeId: string | null;
isConfigDrawerCollapsed: boolean;
isSnapToCanvasEnabled: boolean;
};
const initialWorkflowEditorState: TWorkflowEditorState = {
workflow: null,
workflowName: "",
workflowDescription: "",
definition: null,
flowNodes: [],
selectedNodeId: null,
isConfigDrawerCollapsed: false,
isSnapToCanvasEnabled: true,
};
export const workflowEditorAtom = atom<TWorkflowEditorState>(initialWorkflowEditorState);
export const workflowAtom = atom((get) => get(workflowEditorAtom).workflow);
export const workflowNameAtom = atom((get) => get(workflowEditorAtom).workflowName);
export const workflowDescriptionAtom = atom((get) => get(workflowEditorAtom).workflowDescription);
export const workflowDefinitionAtom = atom((get) => get(workflowEditorAtom).definition);
export const workflowFlowNodesAtom = atom((get) => get(workflowEditorAtom).flowNodes);
export const selectedWorkflowNodeIdAtom = atom((get) => get(workflowEditorAtom).selectedNodeId);
export const isWorkflowConfigDrawerCollapsedAtom = atom(
(get) => get(workflowEditorAtom).isConfigDrawerCollapsed
);
export const isWorkflowSnapToCanvasEnabledAtom = atom((get) => get(workflowEditorAtom).isSnapToCanvasEnabled);
export const hydrateWorkflowEditorAtom = atom(
null,
(
_get,
set,
{
workflow,
flowNodes,
}: {
workflow: TWorkflow;
flowNodes: Array<Node<TWorkflowNodeData>>;
}
) => {
set(
workflowEditorAtom,
produce(initialWorkflowEditorState, (draft) => {
draft.workflow = workflow;
draft.workflowName = workflow.name;
draft.workflowDescription = workflow.description ?? "";
draft.definition = workflow.definition;
draft.flowNodes = flowNodes;
draft.selectedNodeId = workflow.definition.trigger.id;
})
);
}
);
export const setWorkflowAtom = atom(null, (get, set, workflow: TWorkflow) => {
set(
workflowEditorAtom,
produce(get(workflowEditorAtom), (draft) => {
draft.workflow = workflow;
draft.workflowName = workflow.name;
draft.workflowDescription = workflow.description ?? "";
})
);
});
export const setWorkflowNameAtom = atom(null, (get, set, workflowName: string) => {
set(
workflowEditorAtom,
produce(get(workflowEditorAtom), (draft) => {
draft.workflowName = workflowName;
})
);
});
export const setWorkflowDescriptionAtom = atom(null, (get, set, workflowDescription: string) => {
set(
workflowEditorAtom,
produce(get(workflowEditorAtom), (draft) => {
draft.workflowDescription = workflowDescription;
})
);
});
export const setWorkflowDefinitionAtom = atom(
null,
(get, set, update: SetStateAction<TWorkflowDefinition | null>) => {
const currentState = get(workflowEditorAtom);
const nextDefinition = typeof update === "function" ? update(currentState.definition) : update;
set(
workflowEditorAtom,
produce(currentState, (draft) => {
draft.definition = nextDefinition;
})
);
}
);
export const setWorkflowFlowNodesAtom = atom(
null,
(get, set, update: SetStateAction<Array<Node<TWorkflowNodeData>>>) => {
const currentState = get(workflowEditorAtom);
const nextFlowNodes = typeof update === "function" ? update(currentState.flowNodes) : update;
set(
workflowEditorAtom,
produce(currentState, (draft) => {
draft.flowNodes = nextFlowNodes;
})
);
}
);
export const setSelectedWorkflowNodeIdAtom = atom(null, (get, set, selectedNodeId: string | null) => {
set(
workflowEditorAtom,
produce(get(workflowEditorAtom), (draft) => {
draft.selectedNodeId = selectedNodeId;
})
);
});
export const toggleWorkflowConfigDrawerAtom = atom(null, (get, set) => {
set(
workflowEditorAtom,
produce(get(workflowEditorAtom), (draft) => {
draft.isConfigDrawerCollapsed = !draft.isConfigDrawerCollapsed;
})
);
});
export const setWorkflowSnapToCanvasEnabledAtom = atom(null, (get, set, isSnapToCanvasEnabled: boolean) => {
set(
workflowEditorAtom,
produce(get(workflowEditorAtom), (draft) => {
draft.isSnapToCanvasEnabled = isSnapToCanvasEnabled;
})
);
});
@@ -1,45 +0,0 @@
import type {
TWorkflowDefinition,
TWorkflowRunData,
TWorkflowRunStatus,
TWorkflowStatus,
} from "@formbricks/types/workflows";
export type TWorkflowRunSummary = {
id: string;
workflowId: string;
workspaceId: string;
status: TWorkflowRunStatus;
triggerEvent: string;
surveyId: string | null;
responseId: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
startedAt: string | null;
finishedAt: string | null;
};
export type TWorkflowRunWorkflowSummary = {
id: string;
name: string;
};
export type TWorkflowRun = TWorkflowRunSummary & {
triggerPayload: unknown;
data: TWorkflowRunData;
workflow?: TWorkflowRunWorkflowSummary;
};
export type TWorkflow = {
id: string;
name: string;
description: string | null;
status: TWorkflowStatus;
workspaceId: string;
createdBy: string | null;
definition: TWorkflowDefinition;
createdAt: string;
updatedAt: string;
lastRun: TWorkflowRunSummary | null;
};
+3 -6
View File
@@ -21,8 +21,8 @@
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
},
"dependencies": {
"@boxyhq/saml-jackson": "26.2.0",
"@cubejs-client/core": "1.6.6",
"@boxyhq/saml-jackson": "26.2.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -33,8 +33,8 @@
"@formbricks/email": "workspace:*",
"@formbricks/hub": "0.5.0",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/jobs": "workspace:*",
"@formbricks/js-core": "workspace:*",
"@formbricks/jobs": "workspace:*",
"@formbricks/logger": "workspace:*",
"@formbricks/storage": "workspace:*",
"@formbricks/surveys": "workspace:*",
@@ -81,7 +81,6 @@
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-query": "5.99.2",
"@tanstack/react-table": "8.21.3",
"@xyflow/react": "12.9.3",
"bcryptjs": "3.0.3",
"boring-avatars": "2.0.4",
"class-variance-authority": "0.7.1",
@@ -95,10 +94,8 @@
"i18next": "25.8.20",
"i18next-icu": "2.4.3",
"i18next-resources-to-backend": "1.2.1",
"immer": "11.1.8",
"isomorphic-dompurify": "3.9.0",
"jiti": "2.6.1",
"jotai": "2.20.0",
"jsonwebtoken": "9.0.3",
"lexical": "0.41.0",
"lucide-react": "0.577.0",
@@ -119,8 +116,8 @@
"react-colorful": "5.6.2",
"react-confetti": "6.4.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react-grid-layout": "2.2.2",
"react-dom": "19.2.6",
"react-hook-form": "7.71.2",
"react-hot-toast": "2.6.0",
"react-i18next": "16.5.8",
+28
View File
@@ -81,5 +81,33 @@
"budgetPercentIncreaseRed": 20,
"minimumChangeThreshold": 0,
"showDetails": true
},
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.13",
"@protobufjs/utf8": "1.1.1",
"@tootallnate/once": "3.0.1",
"@xmldom/xmldom": "0.9.10",
"ajv@6": "6.14.0",
"axios": "1.15.2",
"effect": "3.20.0",
"fast-uri": "3.1.2",
"fast-xml-parser": "5.7.0",
"hono": "4.12.18",
"ip-address": "10.1.1",
"lodash": "4.18.1",
"node-forge": "1.4.0",
"postcss": "8.5.14",
"protobufjs@7": "7.5.8",
"protobufjs@8": "8.2.0",
"tar": "7.5.15",
"uuid@11": "11.1.1"
},
"comments": {
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs) - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g) - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6) - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
}
}
}
@@ -1,78 +0,0 @@
-- CreateEnum
CREATE TYPE "public"."WorkflowStatus" AS ENUM ('draft', 'active', 'disabled');
-- CreateEnum
CREATE TYPE "public"."WorkflowRunStatus" AS ENUM ('queued', 'running', 'completed', 'failed', 'canceled');
-- CreateTable
CREATE TABLE "public"."Workflow" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"status" "public"."WorkflowStatus" NOT NULL DEFAULT 'draft',
"workspaceId" TEXT NOT NULL,
"createdBy" TEXT,
"definition" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "Workflow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."WorkflowRun" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"workflowId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"status" "public"."WorkflowRunStatus" NOT NULL DEFAULT 'queued',
"trigger_event" TEXT NOT NULL,
"surveyId" TEXT,
"responseId" TEXT,
"trigger_payload" JSONB NOT NULL DEFAULT '{}',
"data" JSONB NOT NULL DEFAULT '{}',
"error" TEXT,
"started_at" TIMESTAMP(3),
"finished_at" TIMESTAMP(3),
CONSTRAINT "WorkflowRun_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Workflow_id_workspaceId_key" ON "public"."Workflow"("id", "workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "Workflow_workspaceId_name_key" ON "public"."Workflow"("workspaceId", "name");
-- CreateIndex
CREATE INDEX "Workflow_workspaceId_status_idx" ON "public"."Workflow"("workspaceId", "status");
-- CreateIndex
CREATE INDEX "Workflow_workspaceId_updated_at_idx" ON "public"."Workflow"("workspaceId", "updated_at");
-- CreateIndex
CREATE INDEX "WorkflowRun_workspaceId_created_at_idx" ON "public"."WorkflowRun"("workspaceId", "created_at");
-- CreateIndex
CREATE INDEX "WorkflowRun_workflowId_workspaceId_created_at_idx" ON "public"."WorkflowRun"("workflowId", "workspaceId", "created_at");
-- CreateIndex
CREATE INDEX "WorkflowRun_status_created_at_idx" ON "public"."WorkflowRun"("status", "created_at");
-- CreateIndex
CREATE INDEX "WorkflowRun_responseId_idx" ON "public"."WorkflowRun"("responseId");
-- AddForeignKey
ALTER TABLE "public"."Workflow" ADD CONSTRAINT "Workflow_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Workflow" ADD CONSTRAINT "Workflow_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."WorkflowRun" ADD CONSTRAINT "WorkflowRun_workflowId_workspaceId_fkey" FOREIGN KEY ("workflowId", "workspaceId") REFERENCES "public"."Workflow"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."WorkflowRun" ADD CONSTRAINT "WorkflowRun_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."WorkflowRun" ADD CONSTRAINT "WorkflowRun_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "public"."Response"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1 +0,0 @@
ALTER TYPE "public"."WorkflowStatus" RENAME VALUE 'active' TO 'enabled';
@@ -1 +0,0 @@
ALTER TABLE "public"."Workflow" ADD COLUMN "description" TEXT;
-66
View File
@@ -22,20 +22,6 @@ enum PipelineTriggers {
responseFinished
}
enum WorkflowStatus {
draft
enabled
disabled
}
enum WorkflowRunStatus {
queued
running
completed
failed
canceled
}
enum WebhookSource {
user
zapier
@@ -196,7 +182,6 @@ model Response {
language String?
displayId String? @unique
display Display? @relation(fields: [displayId], references: [id])
workflowRuns WorkflowRun[]
@@unique([surveyId, singleUseId])
@@index([createdAt])
@@ -637,8 +622,6 @@ model Workspace {
feedbackDirectoryWorkspaces FeedbackDirectoryWorkspace[]
charts Chart[]
dashboards Dashboard[]
workflows Workflow[]
workflowRuns WorkflowRun[]
surveys Survey[]
contacts Contact[]
@@ -654,54 +637,6 @@ model Workspace {
@@unique([organizationId, name])
}
model Workflow {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
description String?
status WorkflowStatus @default(draft)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
creator User? @relation("workflowCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
/// [WorkflowDefinition]
definition Json @default("{}")
runs WorkflowRun[]
@@unique([id, workspaceId])
@@unique([workspaceId, name])
@@index([workspaceId, status])
@@index([workspaceId, updatedAt])
}
model WorkflowRun {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
workflow Workflow @relation(fields: [workflowId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
workflowId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
status WorkflowRunStatus @default(queued)
triggerEvent String @map(name: "trigger_event")
surveyId String?
response Response? @relation(fields: [responseId], references: [id], onDelete: SetNull)
responseId String?
/// [WorkflowTriggerPayload]
triggerPayload Json @default("{}") @map(name: "trigger_payload")
/// [WorkflowRunData]
data Json @default("{}")
error String?
startedAt DateTime? @map(name: "started_at")
finishedAt DateTime? @map(name: "finished_at")
@@index([workspaceId, createdAt])
@@index([workflowId, workspaceId, createdAt])
@@index([status, createdAt])
@@index([responseId])
}
/// Represents the top-level organizational hierarchy in Formbricks.
/// Self-hosting instances typically have a single organization, while Formbricks Cloud
/// supports multiple organizations with multi-tenancy.
@@ -982,7 +917,6 @@ model User {
surveys Survey[]
charts Chart[] @relation("chartCreatedBy")
dashboards Dashboard[] @relation("dashboardCreatedBy")
workflows Workflow[] @relation("workflowCreatedBy")
connectors Connector[]
teamUsers TeamUser[]
lastLoginAt DateTime?
-1
View File
@@ -21,7 +21,6 @@
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-email/preview-server": "5.2.11",
"autoprefixer": "10.4.27",
"clsx": "2.1.1",
"postcss": "8.5.14",
-1
View File
@@ -7,7 +7,6 @@ export const JOB_NAMES = {
testLog: "system.test-log",
responsePipeline: "response-pipeline.process",
surveyScheduling: "survey-scheduling.reconcile",
workflowRun: "workflow-run.process",
} as const;
const JOBS_DEFAULT_BACKOFF = Object.freeze({
+1 -7
View File
@@ -4,12 +4,7 @@ import type {
TRecurringBackgroundJobSchedule,
TRunAtBackgroundJobSchedule,
} from "@/src/schedules";
import type {
TResponsePipelineJobData,
TSurveySchedulingJobData,
TTestLogJobData,
TWorkflowRunJobData,
} from "@/src/types";
import type { TResponsePipelineJobData, TSurveySchedulingJobData, TTestLogJobData } from "@/src/types";
export interface JobExecutionContext {
attempt: number;
@@ -60,7 +55,6 @@ export interface BackgroundJobProducer {
enqueueResponsePipeline: (data: TResponsePipelineJobData) => Promise<EnqueuedJob>;
enqueueSurveyScheduling: (data: TSurveySchedulingJobData) => Promise<EnqueuedJob>;
enqueueTestLog: (data: TTestLogJobData) => Promise<EnqueuedJob>;
enqueueWorkflowRun: (data: TWorkflowRunJobData) => Promise<EnqueuedJob>;
scheduleResponsePipelineAt: (
schedule: TRunAtBackgroundJobSchedule,
data: TResponsePipelineJobData
+1 -12
View File
@@ -3,13 +3,7 @@ import { type AnyBackgroundJobDefinition, toAnyBackgroundJobDefinition } from "@
import { processResponsePipelineJob } from "@/src/processors/response-pipeline";
import { processSurveySchedulingJob } from "@/src/processors/survey-scheduling";
import { processTestLogJob } from "@/src/processors/test-log";
import { processWorkflowRunJob } from "@/src/processors/workflow-run";
import {
ZResponsePipelineJobData,
ZSurveySchedulingJobData,
ZTestLogJobData,
ZWorkflowRunJobData,
} from "@/src/types";
import { ZResponsePipelineJobData, ZSurveySchedulingJobData, ZTestLogJobData } from "@/src/types";
export const backgroundJobDefinitions = {
[JOB_NAMES.responsePipeline]: toAnyBackgroundJobDefinition({
@@ -27,11 +21,6 @@ export const backgroundJobDefinitions = {
name: JOB_NAMES.testLog,
schema: ZTestLogJobData,
}),
[JOB_NAMES.workflowRun]: toAnyBackgroundJobDefinition({
handle: processWorkflowRunJob,
name: JOB_NAMES.workflowRun,
schema: ZWorkflowRunJobData,
}),
} as const satisfies Record<string, AnyBackgroundJobDefinition>;
export type TBackgroundJobName = keyof typeof backgroundJobDefinitions;
-4
View File
@@ -12,7 +12,6 @@ export {
enqueueResponsePipelineJob,
enqueueSurveySchedulingJob,
enqueueTestLogJob,
enqueueWorkflowRunJob,
getBackgroundJobProducer,
removeRecurringSurveySchedulingJobSchedule,
scheduleResponsePipelineJobAt,
@@ -25,7 +24,6 @@ export {
export { processResponsePipelineJob } from "./processors/response-pipeline";
export { processSurveySchedulingJob } from "./processors/survey-scheduling";
export { processTestLogJob } from "./processors/test-log";
export { processWorkflowRunJob } from "./processors/workflow-run";
export { startJobsRuntime } from "./runtime";
export {
ZBackgroundJobScheduleIdentity,
@@ -51,13 +49,11 @@ export {
ZResponsePipelineJobData,
ZSurveySchedulingJobData,
ZTestLogJobData,
ZWorkflowRunJobData,
} from "./types";
export type {
TResponsePipelineEvent,
TResponsePipelineJobData,
TSurveySchedulingJobData,
TTestLogJobData,
TWorkflowRunJobData,
} from "./types";
/* v8 ignore stop */
-67
View File
@@ -27,7 +27,6 @@ describe("@formbricks/jobs processor registry", () => {
expect(getJobProcessor(JOB_NAMES.testLog)).toBeDefined();
expect(getJobProcessor(JOB_NAMES.responsePipeline)).toBeDefined();
expect(getJobProcessor(JOB_NAMES.surveyScheduling)).toBeDefined();
expect(getJobProcessor(JOB_NAMES.workflowRun)).toBeDefined();
expect(getBackgroundJobDefinition(JOB_NAMES.testLog)).toBeDefined();
});
@@ -247,72 +246,6 @@ describe("@formbricks/jobs processor registry", () => {
);
});
test("fails fast for the unimplemented workflow run processor", async () => {
await expect(
processJob({
attemptsMade: 0,
data: {
workflowId: "cm8cmpnjj000108jfdr9dfqe5",
workflowRunId: "cm8cmpnjj000108jfdr9dfqe4",
workspaceId: "cm8cmpnjj000108jfdr9dfqe8",
},
id: "job-workflow-run",
name: JOB_NAMES.workflowRun,
opts: { attempts: 1 },
queueName: "background-jobs",
} as never)
).rejects.toThrow("BullMQ workflow run processor override missing");
expect(mockError).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "job-workflow-run",
jobName: JOB_NAMES.workflowRun,
workflowRunId: "cm8cmpnjj000108jfdr9dfqe4",
workspaceId: "cm8cmpnjj000108jfdr9dfqe8",
}),
"BullMQ workflow run processor override is not registered"
);
});
test("uses workflow run handler overrides when provided", async () => {
const overrideHandler = vi.fn().mockResolvedValue(undefined);
await expect(
processJob(
{
attemptsMade: 0,
data: {
workflowId: "cm8cmpnjj000108jfdr9dfqe5",
workflowRunId: "cm8cmpnjj000108jfdr9dfqe4",
workspaceId: "cm8cmpnjj000108jfdr9dfqe8",
},
id: "job-workflow-run-override",
name: JOB_NAMES.workflowRun,
opts: { attempts: 1 },
queueName: "background-jobs",
} as never,
{
[JOB_NAMES.workflowRun]: overrideHandler,
}
)
).resolves.toBeUndefined();
expect(overrideHandler).toHaveBeenCalledWith(
{
workflowId: "cm8cmpnjj000108jfdr9dfqe5",
workflowRunId: "cm8cmpnjj000108jfdr9dfqe4",
workspaceId: "cm8cmpnjj000108jfdr9dfqe8",
},
{
attempt: 1,
jobId: "job-workflow-run-override",
jobName: JOB_NAMES.workflowRun,
maxAttempts: 1,
queueName: "background-jobs",
}
);
});
test("throws for unknown jobs", async () => {
await expect(
processJob({
@@ -1,22 +0,0 @@
import { logger } from "@formbricks/logger";
import type { JobHandler } from "@/src/contracts";
import type { TWorkflowRunJobData } from "@/src/types";
export const processWorkflowRunJob: JobHandler<TWorkflowRunJobData> = (data, context) => {
logger.error(
{
attempt: context.attempt,
jobId: context.jobId,
jobName: context.jobName,
queueName: context.queueName,
workflowId: data.workflowId,
workflowRunId: data.workflowRunId,
workspaceId: data.workspaceId,
},
"BullMQ workflow run processor override is not registered"
);
throw new Error(
`BullMQ workflow run processor override missing for job ${context.jobId} (${data.workspaceId}/${data.workflowRunId})`
);
};
-35
View File
@@ -13,7 +13,6 @@ import {
enqueueResponsePipelineJob,
enqueueSurveySchedulingJob,
enqueueTestLogJob,
enqueueWorkflowRunJob,
getBackgroundJobProducer,
getJobsQueue,
removeRecurringSurveySchedulingJobSchedule,
@@ -79,12 +78,6 @@ const surveySchedulingJobData = {
scope: "global" as const,
};
const workflowRunJobData = {
workflowId: "cm8cmpnjj000108jfdr9dfqe5",
workflowRunId: "cm8cmpnjj000108jfdr9dfqe4",
workspaceId: "cm8cmpnjj000108jfdr9dfqe8",
};
vi.mock("@formbricks/logger", () => ({
logger: {
error: mockLoggerError,
@@ -181,16 +174,6 @@ describe("@formbricks/jobs queue helpers", () => {
expect(mockQueueAdd).toHaveBeenCalledWith(JOB_NAMES.surveyScheduling, surveySchedulingJobData, undefined);
});
test("enqueues workflow run jobs with one PoC attempt", async () => {
const mockJob = { id: "job-workflow-run-1" };
mockQueueAdd.mockResolvedValue(mockJob);
const job = await enqueueWorkflowRunJob(workflowRunJobData);
expect(job).toBe(mockJob);
expect(mockQueueAdd).toHaveBeenCalledWith(JOB_NAMES.workflowRun, workflowRunJobData, { attempts: 1 });
});
test("exposes an engine-neutral producer interface", async () => {
const producer = getBackgroundJobProducer();
mockQueueAdd.mockResolvedValue({
@@ -225,24 +208,6 @@ describe("@formbricks/jobs queue helpers", () => {
});
});
test("exposes workflow run enqueues through the engine-neutral producer interface", async () => {
const producer = getBackgroundJobProducer();
mockQueueAdd.mockResolvedValue({
id: "job-workflow-run-2",
name: JOB_NAMES.workflowRun,
queueName: JOBS_QUEUE_NAME,
});
const job = await producer.enqueueWorkflowRun(workflowRunJobData);
expect(job).toEqual({
jobId: "job-workflow-run-2",
jobName: JOB_NAMES.workflowRun,
queueName: JOBS_QUEUE_NAME,
});
expect(mockQueueAdd).toHaveBeenCalledWith(JOB_NAMES.workflowRun, workflowRunJobData, { attempts: 1 });
});
test("schedules a delayed job using the runAt schedule type", async () => {
mockQueueAdd.mockResolvedValue({ id: "job-3" });
-11
View File
@@ -23,7 +23,6 @@ import {
type TResponsePipelineJobData,
type TSurveySchedulingJobData,
type TTestLogJobData,
type TWorkflowRunJobData,
} from "@/src/types";
export interface JobsQueueHandle {
@@ -242,15 +241,6 @@ export const enqueueSurveySchedulingJob = async (data: TSurveySchedulingJobData)
}
};
export const enqueueWorkflowRunJob = async (data: TWorkflowRunJobData): Promise<Job> => {
try {
return await enqueueBackgroundJob(JOB_NAMES.workflowRun, data, { attempts: 1 });
} catch (error) {
logger.error({ err: error, jobName: JOB_NAMES.workflowRun }, "Failed to enqueue BullMQ workflow run job");
throw error;
}
};
export const scheduleTestLogJobAt = async (
schedule: TRunAtBackgroundJobSchedule,
data: TTestLogJobData
@@ -385,7 +375,6 @@ export const getBackgroundJobProducer = (): BackgroundJobProducer => ({
enqueueResponsePipeline: async (data) => toEnqueuedJob(await enqueueResponsePipelineJob(data)),
enqueueSurveyScheduling: async (data) => toEnqueuedJob(await enqueueSurveySchedulingJob(data)),
enqueueTestLog: async (data) => toEnqueuedJob(await enqueueTestLogJob(data)),
enqueueWorkflowRun: async (data) => toEnqueuedJob(await enqueueWorkflowRunJob(data)),
scheduleResponsePipelineAt: async (schedule, data) =>
toEnqueuedJob(await scheduleResponsePipelineJobAt(schedule, data)),
scheduleSurveySchedulingAt: async (schedule, data) =>
-8
View File
@@ -39,11 +39,3 @@ export const ZSurveySchedulingJobData = z.object({
});
export type TSurveySchedulingJobData = z.infer<typeof ZSurveySchedulingJobData>;
export const ZWorkflowRunJobData = z.object({
workflowRunId: z.cuid2(),
workflowId: z.cuid2(),
workspaceId: z.cuid2(),
});
export type TWorkflowRunJobData = z.infer<typeof ZWorkflowRunJobData>;
-278
View File
@@ -1,278 +0,0 @@
import { z } from "zod";
export const ZWorkflowStatus = z.enum(["draft", "enabled", "disabled"]);
export const ZWorkflowRunStatus = z.enum(["queued", "running", "completed", "failed", "canceled"]);
export const ZWorkflowTriggerType = z.enum(["response.completed"]);
export const ZWorkflowNodeType = z.enum(["ifElse", "action"]);
export const ZWorkflowActionType = z.enum(["sendEmailPreview", "sendWebhookPreview"]);
export const ZDeferredWorkflowActionType = z.enum(["compute"]);
export const ZWorkflowNodePosition = z.object({
x: z.number(),
y: z.number(),
});
export const ZWorkflowNodeUi = z
.object({
position: ZWorkflowNodePosition.optional(),
})
.optional();
export const ZWorkflowDataRef = z.object({
type: z.literal("ref"),
path: z.string().min(1),
});
export const ZResponseCompletedTriggerConfig = z.object({
type: z.literal("response.completed"),
surveyId: z.cuid2().nullable().optional(),
});
export const ZWorkflowTriggerNode = z.object({
id: z.string().min(1),
type: z.literal("trigger"),
config: ZResponseCompletedTriggerConfig,
ui: ZWorkflowNodeUi,
});
export const ZWorkflowConditionOperator = z.enum([
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"contains",
"doesNotContain",
"isEmpty",
"isNotEmpty",
]);
export const ZWorkflowConditionValue = z.union([z.string(), z.number(), z.boolean(), ZWorkflowDataRef]);
export type TWorkflowCondition = {
id: string;
left: z.infer<typeof ZWorkflowDataRef>;
operator: z.infer<typeof ZWorkflowConditionOperator>;
right?: z.infer<typeof ZWorkflowConditionValue>;
};
export type TWorkflowConditionGroup = {
id: string;
connector: "and" | "or";
conditions: Array<TWorkflowCondition | TWorkflowConditionGroup>;
};
export const ZWorkflowCondition: z.ZodType<TWorkflowCondition> = z.object({
id: z.string().min(1),
left: ZWorkflowDataRef,
operator: ZWorkflowConditionOperator,
right: ZWorkflowConditionValue.optional(),
});
export const ZWorkflowConditionGroup: z.ZodType<TWorkflowConditionGroup> = z.lazy(() =>
z.object({
id: z.string().min(1),
connector: z.enum(["and", "or"]),
conditions: z.array(z.union([ZWorkflowCondition, ZWorkflowConditionGroup])).min(1),
})
);
export const ZIfElseNode = z.object({
id: z.string().min(1),
type: z.literal("ifElse"),
config: z.object({
condition: ZWorkflowConditionGroup,
}),
ui: ZWorkflowNodeUi,
});
export const ZSendEmailPreviewActionConfig = z.object({
to: z.string().min(1),
replyTo: z.array(z.email()).default([]),
subject: z.string().min(1),
body: z.string().min(1),
includeResponseData: z.boolean().default(false),
});
export const ZSendWebhookPreviewActionConfig = z.object({
url: z.url(),
method: z.literal("POST").default("POST"),
headers: z.record(z.string(), z.string()).default({}),
});
export const ZSendEmailPreviewActionNode = z.object({
id: z.string().min(1),
type: z.literal("action"),
actionType: z.literal("sendEmailPreview"),
config: ZSendEmailPreviewActionConfig,
ui: ZWorkflowNodeUi,
});
export const ZSendWebhookPreviewActionNode = z.object({
id: z.string().min(1),
type: z.literal("action"),
actionType: z.literal("sendWebhookPreview"),
config: ZSendWebhookPreviewActionConfig,
ui: ZWorkflowNodeUi,
});
export const ZWorkflowActionNode = z.discriminatedUnion("actionType", [
ZSendEmailPreviewActionNode,
ZSendWebhookPreviewActionNode,
]);
export const ZWorkflowNode = z.union([ZIfElseNode, ZWorkflowActionNode]);
export const ZWorkflowEdge = z.object({
id: z.string().min(1),
source: z.string().min(1),
target: z.string().min(1),
branch: z.enum(["then", "else", "next"]).default("next"),
});
export const ZWorkflowDefinition = z
.object({
schemaVersion: z.literal(1),
trigger: ZWorkflowTriggerNode,
nodes: z.array(ZWorkflowNode),
edges: z.array(ZWorkflowEdge),
entryNodeId: z.string().min(1),
})
.superRefine((definition, ctx) => {
const parsedNodes = definition.nodes.filter(
(node): node is TWorkflowNode =>
Boolean(node) && typeof node === "object" && "id" in node && typeof node.id === "string"
);
if (definition.entryNodeId !== definition.trigger.id) {
ctx.addIssue({
code: "custom",
path: ["entryNodeId"],
message: "The entry node must be the workflow trigger.",
});
}
const ids = [definition.trigger.id, ...parsedNodes.map((node) => node.id)];
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
for (const duplicateId of new Set(duplicateIds)) {
ctx.addIssue({
code: "custom",
path: ["nodes"],
message: `Duplicate workflow node id: ${duplicateId}`,
});
}
const idSet = new Set(ids);
for (const [index, edge] of definition.edges.entries()) {
if (!idSet.has(edge.source)) {
ctx.addIssue({
code: "custom",
path: ["edges", index, "source"],
message: `Unknown edge source: ${edge.source}`,
});
}
if (!idSet.has(edge.target)) {
ctx.addIssue({
code: "custom",
path: ["edges", index, "target"],
message: `Unknown edge target: ${edge.target}`,
});
}
const sourceNode =
edge.source === definition.trigger.id
? definition.trigger
: parsedNodes.find((node) => node.id === edge.source);
if (sourceNode?.type === "ifElse" && edge.branch === "next") {
ctx.addIssue({
code: "custom",
path: ["edges", index, "branch"],
message: "If/Else edges must use then or else branches.",
});
}
if (sourceNode?.type !== "ifElse" && edge.branch !== "next") {
ctx.addIssue({
code: "custom",
path: ["edges", index, "branch"],
message: "Only If/Else nodes can use then or else branches.",
});
}
}
const triggerEdges = definition.edges.filter((edge) => edge.source === definition.trigger.id);
if (triggerEdges.length !== 1) {
ctx.addIssue({
code: "custom",
path: ["edges"],
message: "A PoC workflow must have exactly one outgoing trigger edge.",
});
}
for (const ifElseNode of parsedNodes.filter((node) => node.type === "ifElse")) {
const outgoingEdges = definition.edges.filter((edge) => edge.source === ifElseNode.id);
if (outgoingEdges.filter((edge) => edge.branch === "then").length !== 1) {
ctx.addIssue({
code: "custom",
path: ["edges"],
message: `If/Else node ${ifElseNode.id} must have exactly one then edge.`,
});
}
if (outgoingEdges.filter((edge) => edge.branch === "else").length !== 1) {
ctx.addIssue({
code: "custom",
path: ["edges"],
message: `If/Else node ${ifElseNode.id} must have exactly one else edge.`,
});
}
}
});
export const ZWorkflowStepResult = z.object({
nodeId: z.string().min(1),
status: z.enum(["completed", "failed", "skipped"]),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.string().optional(),
startedAt: z.string().datetime().optional(),
finishedAt: z.string().datetime().optional(),
});
export const ZWorkflowRunLog = z.object({
level: z.enum(["info", "warn", "error"]),
message: z.string(),
timestamp: z.string().datetime(),
nodeId: z.string().optional(),
});
export const ZWorkflowRunData = z.object({
triggerPayload: z.unknown().optional(),
steps: z.array(ZWorkflowStepResult).default([]),
finalOutput: z.unknown().optional(),
logs: z.array(ZWorkflowRunLog).default([]),
});
export const ZWorkflowTriggerPayload = z.object({
event: z.literal("response.completed"),
workspaceId: z.cuid2(),
surveyId: z.cuid2(),
response: z.unknown(),
});
export type TWorkflowStatus = z.infer<typeof ZWorkflowStatus>;
export type TWorkflowRunStatus = z.infer<typeof ZWorkflowRunStatus>;
export type TWorkflowTriggerType = z.infer<typeof ZWorkflowTriggerType>;
export type TWorkflowActionType = z.infer<typeof ZWorkflowActionType>;
export type TWorkflowDataRef = z.infer<typeof ZWorkflowDataRef>;
export type TWorkflowTriggerNode = z.infer<typeof ZWorkflowTriggerNode>;
export type TWorkflowNode = z.infer<typeof ZWorkflowNode>;
export type TWorkflowActionNode = z.infer<typeof ZWorkflowActionNode>;
export type TIfElseNode = z.infer<typeof ZIfElseNode>;
export type TWorkflowEdge = z.infer<typeof ZWorkflowEdge>;
export type TWorkflowDefinition = z.infer<typeof ZWorkflowDefinition>;
export type TWorkflowStepResult = z.infer<typeof ZWorkflowStepResult>;
export type TWorkflowRunData = z.infer<typeof ZWorkflowRunData>;
export type TWorkflowTriggerPayload = z.infer<typeof ZWorkflowTriggerPayload>;
+5690 -3082
View File
File diff suppressed because it is too large Load Diff
+3 -41
View File
@@ -5,46 +5,8 @@ packages:
# Allow lifecycle scripts for packages that need to build native binaries
# Required for pnpm v10+ which blocks scripts by default
onlyBuiltDependencies:
- sharp
- esbuild
- prisma
- "@prisma/client"
- "@prisma/engines"
- '@sentry/cli'
- better-sqlite3
- core-js
- esbuild
- msgpackr-extract
- prisma
- protobufjs
- sharp
- unrs-resolver
# Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version:
# - @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8)
# - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs)
# - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g)
# - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945)
# - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6)
# - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.)
# - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update
overrides:
"@hono/node-server": "1.19.13"
"@protobufjs/utf8": "1.1.1"
"@tootallnate/once": "3.0.1"
"@xmldom/xmldom": "0.9.10"
"ajv@6": "6.14.0"
axios: "1.15.2"
effect: "3.20.0"
fast-uri: "3.1.2"
fast-xml-parser: "5.7.0"
hono: "4.12.18"
ip-address: "10.1.1"
lodash: "4.18.1"
node-forge: "1.4.0"
postcss: "8.5.14"
"protobufjs@7": "7.5.8"
"protobufjs@8": "8.2.0"
tar: "7.5.15"
"uuid@11": "11.1.1"
patchedDependencies:
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
-114
View File
@@ -1,114 +0,0 @@
---
name: workbench-create-milestone
description:
Use when the user asks to create, draft, add, or update a milestone in an existing project planning workflow,
including finding the workbench root, reading GUIDE.md, choosing the next three-digit milestone number, creating or
updating blueprint/milestones/NNN-slug.md, and updating blueprint/MILESTONES.md without duplicating milestone detail
into the index.
---
# Workbench Milestone Creator
Create or update a milestone inside an already established planning workflow.
Do not use this skill to bootstrap, upgrade, or repair the workflow itself. If the project has no standalone
`<workbench-root>/GUIDE.md`, or the user asks to modernize the workflow, use `setup-specs-workflow` instead.
## Workbench Layout
Treat the planning root as the workbench root. In this repository that is `workbench/`.
- Workflow guide: `<workbench-root>/GUIDE.md`
- Product truth: `<workbench-root>/blueprint/`
- Milestone index: `<workbench-root>/blueprint/MILESTONES.md`
- Milestone records: `<workbench-root>/blueprint/milestones/`
- Business rules: `<workbench-root>/blueprint/business-rules/`
- Decisions: `<workbench-root>/blueprint/decisions/`
- Plans/checkpoints/bug fixes/prompts/templates: `<workbench-root>/cowork/`
- Coordination board: `<workbench-root>/cowork/COORDINATOR.md`
- Research notes and reference assets: `<workbench-root>/research/`
Blueprint docs are human-owned product truth. AI may formalize, structure, and improve a human prompt or draft, but the
concepts must come from humans and the final milestone needs human review.
Milestones must end with a review gate line: `- [ ] Reviewed and refined by: TBD`. Do not create plans or start
implementation under a milestone until that line has been completed by a human, e.g.
`- [ ] Reviewed and refined by: Javier`.
## Workflow
1. Resolve the target planning root:
- Use the root named by the user when provided.
- Otherwise prefer a directory with `GUIDE.md` and `blueprint/MILESTONES.md`.
- Check common roots in this order: `workbench/`, `.specs/`, `docs/`, `project-specs/`, `.agents/specs/`, `ai/`.
- If multiple plausible roots exist, choose the one whose `GUIDE.md` describes the active workflow or ask when the
answer is not clear.
2. Read project context in tiers:
- Always read only the root agent guide if present, `<workbench-root>/GUIDE.md`,
`<workbench-root>/blueprint/PRODUCT.md`, `<workbench-root>/blueprint/MILESTONES.md`,
relevant `<workbench-root>/blueprint/business-rules/` files, and `<workbench-root>/cowork/COORDINATOR.md`.
- Read `<workbench-root>/blueprint/milestones/` filenames or index rows before opening milestone records.
- Open only milestone records, decisions, plans, checkpoints, business rules, research notes, setup docs, design
docs, or implementation files that are directly relevant to the requested milestone.
- Do not bulk-read the planning root or every record in a directory.
3. Minimum context sources:
- root `AGENTS.md`, `CLAUDE.md`, or `.cursor/rules` if present
- `<workbench-root>/GUIDE.md`
- `<workbench-root>/blueprint/PRODUCT.md`
- `<workbench-root>/blueprint/MILESTONES.md`
- relevant `<workbench-root>/blueprint/business-rules/` files
- `<workbench-root>/cowork/COORDINATOR.md`
4. Treat `<workbench-root>/GUIDE.md` as the workflow source of truth for status vocabulary, numbering, file naming,
template usage, and index ownership.
5. Determine whether to create a new milestone or update an existing one:
- If the user names an existing milestone number, title, or record, update that record and its index row.
- Otherwise create a new milestone record.
6. For a new milestone, choose `NNN`:
- Use the user's requested number only when it is exactly three digits and unused.
- Otherwise pick the next unused three-digit number after scanning `<workbench-root>/blueprint/MILESTONES.md` and
`<workbench-root>/blueprint/milestones/*.md`.
- Never reuse retired, cancelled, or superseded milestone numbers.
7. Create or update `<workbench-root>/blueprint/milestones/NNN-kebab-case-title.md`:
- Prefer `<workbench-root>/cowork/templates/MILESTONE.md` when present.
- If the template is missing, follow the milestone requirements in `<workbench-root>/GUIDE.md`.
- Use `Proposed` by default unless the user explicitly says the milestone should be active, blocked, done, or
dropped.
- Keep objective, scope, non-goals, related work, acceptance criteria, risks, drafted plans, phase map, and
checkpoint rollup in the milestone record.
- End the record with `- [ ] Reviewed and refined by: TBD` unless a human has already reviewed and refined it.
8. Update `<workbench-root>/blueprint/MILESTONES.md`:
- Add or update the milestone index row with number, title, status, record link, and summary.
- Keep it as an index only. Do not paste drafted-plan registries, phase maps, risks, acceptance criteria, or
checkpoint rollups into `MILESTONES.md`.
- Update current focus, recommended next plan, latest checkpoint, or execution order only when the user asked for
that or the new milestone clearly changes cross-milestone state.
9. Normalize links and vocabulary:
- Use exactly three-digit milestone IDs.
- Use the exact status vocabulary from `GUIDE.md`.
- Link related PRD sections, business rules, decisions, plans, checkpoints, or research notes when known.
- Mark unknown product facts as `TBD` rather than inventing details.
10. If `<workbench-root>/scripts/validate-workbench.mjs` exists, run
`node <workbench-root>/scripts/validate-workbench.mjs <workbench-root>` and treat failures as blockers.
11. Preserve git index state. Do not stage, unstage, commit, amend, reset, or discard files unless explicitly asked.
## Active Milestone Caution
If the user asks to make the new milestone `Active` and another milestone is already active, do not silently rewrite
roadmap focus. Ask whether to switch focus, or keep the new milestone `Proposed` and mention the conflict.
If the user only asks to "create a milestone", default to `Proposed`.
## Final Response
Report:
- milestone record created or updated
- `MILESTONES.md` row added or changed
- status and milestone number used
- related docs linked or left as `TBD`
- validator/checks run, if any
Then ask the user to read and review the milestone and any related blueprint docs in detail before treating them as
accepted product truth. Tell them to replace the review gate with their name after review and refinement.
Keep the response concise. Do not paste the full milestone unless the user asks.
-182
View File
@@ -1,182 +0,0 @@
---
name: workbench-create-plan
description:
Use when the user asks to create, draft, add, or plan a new implementation plan in an established planning workflow,
including finding the workbench root, reading GUIDE.md, choosing the next milestone-scoped plan number, updating the
target blueprint milestone record, creating a cowork plan file, and asking whether implementation should start
afterward.
---
# Workbench Plan Creator
Create or update an implementation plan inside an already established planning workflow.
Do not use this skill to bootstrap, upgrade, or repair the workflow itself. If the project has no standalone
`<workbench-root>/GUIDE.md`, or the user asks to modernize the workflow, use `setup-specs-workflow` instead.
## Workbench Layout
Treat the planning root as the workbench root. In this repository that is `workbench/`.
- Workflow guide: `<workbench-root>/GUIDE.md`
- Product truth: `<workbench-root>/blueprint/`
- Milestone index: `<workbench-root>/blueprint/MILESTONES.md`
- Milestone records: `<workbench-root>/blueprint/milestones/`
- Business rules: `<workbench-root>/blueprint/business-rules/`
- Decisions: `<workbench-root>/blueprint/decisions/`
- Design/checks/manual QA/security/env docs: `<workbench-root>/blueprint/`
- Plans/checkpoints/bug fixes/prompts/templates: `<workbench-root>/cowork/`
- Plan records: `<workbench-root>/cowork/plans/`
- Checkpoint records: `<workbench-root>/cowork/checkpoints/`
- Bug-fix index: `<workbench-root>/cowork/BUG_FIXES.md`
- Coordination board: `<workbench-root>/cowork/COORDINATOR.md`
- Research notes and reference assets: `<workbench-root>/research/`
Blueprint docs are human-owned product truth. AI may formalize, structure, and improve a human prompt or draft, but
product concepts, milestone direction, and durable rules must come from humans and receive human review.
Milestones and plans must end with a completed human review gate before downstream work proceeds. The required line is
`- [ ] Reviewed and refined by: TBD`; after human review it should become something like
`- [ ] Reviewed and refined by: Javier`.
## Workflow
1. Resolve the target planning root:
- Use the root named by the user when provided.
- Otherwise prefer a directory with `GUIDE.md` and `blueprint/MILESTONES.md`.
- Check common roots in this order: `workbench/`, `.specs/`, `docs/`, `project-specs/`, `.agents/specs/`, `ai/`.
- If multiple plausible roots exist, choose the one whose `GUIDE.md` describes the active workflow or ask when the
answer is not clear.
2. Read project context in tiers:
- Always read only the root agent guide if present, `<workbench-root>/GUIDE.md`,
`<workbench-root>/blueprint/PRODUCT.md`, `<workbench-root>/blueprint/MILESTONES.md`,
`<workbench-root>/cowork/BUG_FIXES.md`, relevant `<workbench-root>/blueprint/business-rules/` files,
`<workbench-root>/cowork/COORDINATOR.md`, `<workbench-root>/blueprint/CHECKS.md`, and
`<workbench-root>/blueprint/MANUAL_QA.md`.
- Read the active or requested milestone record before opening related plans or checkpoints.
- Read `DESIGN.md`, decisions, business-rule records, bug-fix records, research notes, setup docs, security docs, env
docs, and implementation files only when they affect the requested plan.
- Use `rg`, filename scans, and index rows to find relevant records before opening full files.
- Do not bulk-read all plans, checkpoints, decisions, or business-rule records.
3. Minimum context sources:
- root `AGENTS.md`, `CLAUDE.md`, or `.cursor/rules` if present
- `<workbench-root>/GUIDE.md`
- `<workbench-root>/blueprint/PRODUCT.md`
- `<workbench-root>/blueprint/MILESTONES.md`
- `<workbench-root>/cowork/BUG_FIXES.md`
- relevant `<workbench-root>/blueprint/business-rules/` files
- `<workbench-root>/cowork/COORDINATOR.md`
- `<workbench-root>/blueprint/CHECKS.md`
- `<workbench-root>/blueprint/MANUAL_QA.md`
4. Treat `<workbench-root>/GUIDE.md` as the workflow source of truth for status vocabulary, numbering, file naming,
template usage, spec revision rules, checkpoint expectations, and index ownership.
5. Check whether the request belongs in a bug-fix record instead of a plan:
- Use `<workbench-root>/cowork/templates/BUG_FIX.md` and update `<workbench-root>/cowork/BUG_FIXES.md` when the work
is primarily a scoped defect report and fix proposal that does not need milestone sequencing, multiple
implementation phases, or roadmap visibility.
- Continue with plan creation when the fix expands into broad feature work, durable architecture changes, migrations,
cross-domain behavior, or multi-phase delivery.
6. Determine the target milestone:
- Use the milestone named by the user when provided.
- Otherwise use the active milestone from `<workbench-root>/blueprint/MILESTONES.md`.
- If there is no active milestone, pick the clearly requested milestone or ask whether to create/select one.
- If the target milestone is missing a completed human review gate or still says `TBD`, stop before creating the plan
and ask the user to review and refine the milestone first.
7. Choose `PPP` for a new plan:
- Use the user's requested plan number only when it is exactly three digits and unused inside the target milestone.
- Otherwise pick the next unused three-digit plan number after scanning `<workbench-root>/cowork/plans/` and the
target milestone record's **Drafted Plans** section.
- Never reuse cancelled, retired, or superseded plan numbers.
8. Create or update `<workbench-root>/cowork/plans/MMM-PPP-kebab-case-title.md`:
- Prefer `<workbench-root>/cowork/templates/PLAN.md` when present.
- If the template is missing, follow the plan requirements in `<workbench-root>/GUIDE.md`.
- Draft phases A, B, C... with the final phase named **Final review pass**.
- Include definition of done, out of scope, phase acceptance checks, validation plan, manual QA impact, circuit
breakers, risk notes, changelog impact, documentation updates, and decision-record check.
- End the record with `- [ ] Reviewed and refined by: TBD` unless a human has already reviewed and refined it.
9. Update the target milestone record:
- Add or update the plan row in the milestone record's **Drafted Plans** section.
- Keep plan registries and phase maps in the milestone record, not in `MILESTONES.md`.
10. Update `<workbench-root>/blueprint/MILESTONES.md` only when cross-milestone focus changed: active milestone,
recommended next plan, latest checkpoint, milestone status, or recommended execution order.
11. Apply the spec revision rule: material changes to accepted or active plans require an explicit plan update before
implementation continues, and unplanned work that changes behavior, architecture, configuration, APIs, operational
flows, security posture, manual QA coverage, verification commands, or user-facing workflows must update the closest
relevant spec in the same turn.
12. If `<workbench-root>/scripts/validate-workbench.mjs` exists, run
`node <workbench-root>/scripts/validate-workbench.mjs <workbench-root>` and treat failures as blockers.
13. Preserve git index state. Do not stage, unstage, commit, amend, reset, or discard files unless explicitly asked.
## Plan Content Requirements
Include:
- status table with all phases initially `Proposed`
- goal
- definition of done with testable outcomes
- out of scope
- phases with goal, likely files or areas, deliverables, and acceptance checks
- test / validation plan that references `<workbench-root>/blueprint/CHECKS.md`
- manual QA impact that references `<workbench-root>/blueprint/MANUAL_QA.md`
- changelog impact using `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Operations`, `QA / Verification`, or
`None`, plus a short human-readable note when release-visible
- circuit breakers and stop conditions
- risk notes
- decision-record check with links to created, existing, or superseded decisions
- documentation update checklist
- final **Final review pass** phase
## Circuit Breakers
Every plan should tell implementation agents when to stop instead of looping:
- Stop after two repeated failures of the same check with no new evidence or changed approach.
- Stop when requirements, business rules, decisions, or implementation constraints conflict.
- Stop when required verification cannot run and no documented fallback exists.
- Stop when the work expands beyond the active plan's scope.
- Stop before changing public behavior, security posture, data model, deployment flow, or manual QA coverage unless the
relevant spec update is included.
## Implementation Offer
After creating or updating the plan and reporting the files changed, ask the user to read and review the plan, target
milestone, and any related blueprint docs in detail. Do not ask whether implementation should start until the plan has a
completed human review gate.
If the plan already has a completed human review gate, prefer a native choice UI when the host makes one available:
- In Codex, if a `request_user_input` style tool is available, use it before ending the turn. Ask: "Implement this plan
now?" with choices:
- `Implement this plan` - start implementing from the new plan in this thread.
- `Just save the plan` - stop after the plan handoff.
- In Cursor or another host with an equivalent native quick-pick/choice UI, use the closest equivalent.
If the plan already has a completed human review gate and no native choice UI is available, end the final response with a
concise plain-text question:
```text
Want me to start implementing this plan now?
- Implement this plan
- Just save the plan
```
Do not begin implementation until the review gate is completed and the user chooses or clearly says yes. If the user
chooses implementation, continue from the plan using the project's normal plan/checkpoint workflow. If a separate
fresh-agent handoff is more appropriate, offer or generate an implementation prompt instead of editing code immediately.
## Final Response
Report:
- plan or bug-fix record created or updated
- milestone record or `BUG_FIXES.md` row added or changed
- plan or bug-fix number and status used
- related checks/manual QA/spec docs updated or left as `TBD`
- validator/checks run, if any
Then ask the user to read and review the plan and related docs in detail before treating them as accepted direction.
Tell them to replace the review gate with their name after review and refinement. Do not offer to implement an unreviewed
plan.
Keep the response concise. Do not paste the full plan or bug-fix record unless the user asks.
-155
View File
@@ -1,155 +0,0 @@
---
name: workbench-document-decision
description:
Use when the user asks to document, capture, record, create, or supersede an architecture/product/security/operational
decision in an existing project planning workflow, including ADR-style decision records for major durable choices. If
called with no explicit decision text, infer the latest major durable decision from the current chat; if there are
multiple plausible decisions or the decision is unclear, ask a concise clarifying question before editing.
---
# Workbench Decision Documenter
Document a major, durable project decision inside an already established planning workflow.
Use this skill for requests like:
- "workbench-document-decision that we will replace Prisma with Drizzle"
- "write an ADR for choosing X over Y"
- "capture the tradeoff we just discussed"
- "supersede the old decision about auth"
- "should this be a decision record?"
Do not use this skill to bootstrap, upgrade, or repair the workflow itself. If the project has no standalone
`<workbench-root>/GUIDE.md`, or the user asks to modernize the workflow, use `setup-specs-workflow` instead.
## Workbench Layout
Treat the planning root as the workbench root. In this repository that is `workbench/`.
- Workflow guide: `<workbench-root>/GUIDE.md`
- Product truth: `<workbench-root>/blueprint/`
- Decisions: `<workbench-root>/blueprint/decisions/`
- Business rules: `<workbench-root>/blueprint/business-rules/`
- Milestone index and records: `<workbench-root>/blueprint/MILESTONES.md` and `<workbench-root>/blueprint/milestones/`
- Plans/checkpoints/bug fixes/prompts/templates: `<workbench-root>/cowork/`
- Research notes and reference assets: `<workbench-root>/research/`
Blueprint docs are human-owned product truth. AI may formalize, structure, and improve a human prompt or draft, but
durable decisions must come from humans and receive human review.
Decision records must end with `- [ ] Reviewed and refined by: TBD` unless a human has already reviewed and refined
them.
## No-Argument Mode
When the user invokes this skill without explicit decision text, infer the decision from the latest major durable
decision made in the current chat session.
Before editing:
- If exactly one recent major durable decision is clear, document that decision.
- If several decisions were discussed, ask which one to document.
- If the discussion was exploratory and no durable choice was made, ask for the decision or say it belongs in research,
a plan, or a checkpoint instead.
Do not invent a decision from vague conversation history.
## Workflow
1. Resolve the target planning root:
- Use the root named by the user when provided.
- Otherwise prefer a directory with `GUIDE.md` and `blueprint/MILESTONES.md`.
- Check common roots in this order: `workbench/`, `.specs/`, `docs/`, `project-specs/`, `.agents/specs/`, `ai/`.
- If multiple plausible roots exist, choose the one whose `GUIDE.md` describes the active workflow or ask when
unclear.
2. Read project context in tiers:
- Always read only the root agent guide if present, `<workbench-root>/GUIDE.md`,
`<workbench-root>/blueprint/PRODUCT.md`, relevant `<workbench-root>/blueprint/business-rules/` files, and
`<workbench-root>/blueprint/MILESTONES.md`.
- Scan `<workbench-root>/blueprint/decisions/` filenames and use `rg` for relevant terms before opening decision
records.
- Open only existing decisions that may be superseded, contradicted, or directly related.
- Read plans, checkpoints, business-rule records, research notes, setup docs, security docs, env docs, or
implementation files only when they affect the decision.
- Do not bulk-read every decision or every planning record.
3. Minimum context sources:
- root `AGENTS.md`, `CLAUDE.md`, or `.cursor/rules` if present
- `<workbench-root>/GUIDE.md`
- `<workbench-root>/blueprint/PRODUCT.md`
- relevant `<workbench-root>/blueprint/business-rules/` files
- `<workbench-root>/blueprint/MILESTONES.md`
4. Treat `<workbench-root>/GUIDE.md` as the workflow source of truth for status vocabulary, numbering, file naming,
template usage, and supersession rules.
5. Run the decision-worthiness check.
6. Create, supersede, or decline:
- Create a new decision when there is a major durable choice with meaningful alternatives, consequences, or future
impact.
- Supersede an existing accepted decision when the new choice changes or replaces it.
- Decline to write a decision when the topic is local sequencing, implementation detail with no durable tradeoff, or
unresolved exploration. Tell the user where it belongs instead.
7. Choose `NNN` for a new decision:
- Use the user's requested number only when it is exactly three digits and unused.
- Otherwise pick the next unused three-digit number after scanning `<workbench-root>/blueprint/decisions/*.md`.
- Never reuse retired, deprecated, or superseded decision numbers.
8. Write `<workbench-root>/blueprint/decisions/NNN-kebab-case-title.md`:
- Prefer `<workbench-root>/cowork/templates/DECISION.md` when present.
- If the template is missing, follow the decision requirements in `<workbench-root>/GUIDE.md`.
- Use `Accepted` when the user states the decision is made. Use `Proposed` only when the user is asking to draft a
decision for review.
- Include context, decision, consequences, alternatives considered, and follow-ups.
- End the record with `- [ ] Reviewed and refined by: TBD` unless a human has already reviewed and refined it.
- Link related product requirements, milestones, business rules, plans, checkpoints, research notes,
setup/security/env docs, and implementation surfaces when known.
9. If superseding an existing decision:
- Create a new decision record for the replacement.
- Update the old decision status to `Superseded by NNN` unless project guidance says otherwise.
- Cross-link the old and new records.
10. Update related docs only when needed: update `<workbench-root>/blueprint/business-rules/` when current
product/domain behavior changes, milestone records or plans when roadmap or implementation sequencing changes, and
setup, security, env, or design docs when operating rules change.
11. If `<workbench-root>/scripts/validate-workbench.mjs` exists, run
`node <workbench-root>/scripts/validate-workbench.mjs <workbench-root>` and treat failures as blockers.
12. Preserve git index state. Do not stage, unstage, commit, amend, reset, or discard files unless explicitly asked.
## Decision-Worthiness Check
A decision record is usually warranted when the choice is important enough for future agents to understand and:
- selects between meaningful alternatives
- makes a big refactor, architecture shift, persistence/auth/security/deployment change, API strategy change, data-model
direction change, tenancy decision, or long-lived convention
- accepts a tradeoff future agents should not relitigate
- supersedes or contradicts a previous accepted decision
- turns research or a plan discovery result into committed direction
- affects multiple plans, milestones, systems, teams, or operational surfaces
A decision record is usually not warranted when the note is only:
- a routine endpoint, schema, field, UI copy, validation, or configuration change
- a task ordering choice inside one plan
- a temporary implementation detail
- unresolved brainstorming
- a bug fix with no durable tradeoff
- a checkpoint observation that does not change future direction
## Plan Work Rule
When this skill is invoked from plan creation or implementation work, document a decision automatically only for major
choices beyond the local plan. For example, a broad refactor may warrant a decision record; adding a field to one API
endpoint usually belongs in the plan, checkpoint, or business-rule docs instead. Keep local sequencing and phase-level
observations in the plan or checkpoint.
## Final Response
Report:
- decision record created, superseded, or intentionally not created
- decision number, title, and status
- existing decision superseded, if any
- related docs updated or left unchanged
- checks run, if any
Then ask the user to read and review the decision record and related blueprint docs in detail before treating them as
accepted product truth. Tell them to replace the review gate with their name after review and refinement.
Keep the response concise. Do not paste the full decision unless the user asks.
@@ -1,135 +0,0 @@
---
name: workbench-generate-changelog
description:
Use when the user asks to generate, update, draft, or compare a CHANGELOG.md or release notes from .specs, planning
docs, milestones, checkpoints, bug-fix records, decisions, business rules, manual QA, checks, security, env, setup, or
other spec-based project records. Produces a prepend-only, non-destructive dated changelog block in product/spec
language instead of summarizing commit messages.
---
# Workbench Changelog Generator
Generate or update a root `CHANGELOG.md` from planning-spec changes. Prefer product/spec language over commit-message
language.
## Workbench Layout
Treat the planning root as the workbench root. In this repository that is `workbench/`.
- Workflow guide: `<workbench-root>/GUIDE.md`
- Durable product/spec records: `<workbench-root>/blueprint/`
- Milestones: `<workbench-root>/blueprint/MILESTONES.md` and `<workbench-root>/blueprint/milestones/`
- Plans, checkpoints, bug fixes, prompts, and templates: `<workbench-root>/cowork/`
- Bug-fix index: `<workbench-root>/cowork/BUG_FIXES.md`
- Research notes and reference assets: `<workbench-root>/research/`
## Workflow
1. Preserve git index state. Do not stage, unstage, commit, amend, reset, or discard files unless explicitly asked.
2. Resolve the planning root:
- Use the root named by the user when provided.
- Otherwise prefer a directory with `GUIDE.md` and `blueprint/MILESTONES.md`.
- Check common roots in this order: `workbench/`, `.specs/`, `docs/`, `project-specs/`, `.agents/specs/`, `ai/`.
- If multiple plausible roots exist, choose the one whose `GUIDE.md` describes the active workflow or ask when the
answer is not clear.
3. Resolve the comparison range:
- Use explicit base/target refs from the user when provided.
- Otherwise use the latest reachable tag to `HEAD`.
- Otherwise use `origin/main...HEAD`.
- Otherwise use `main...HEAD`.
- If no base can be resolved, ask for a base ref.
4. Gather evidence with read-only Git commands:
- `git diff --name-status <base>...<target> -- <workbench-root>`
- targeted `git diff <base>...<target> -- <changed-spec-paths>`
- `git show <ref>:<path>` when the before/after shape is easier to inspect separately
5. Read changed spec records in tiers:
- Start from the `git diff --name-status` path list.
- Read only changed files and directly linked source records needed to understand the release impact.
- Prefer explicit `Changelog Impact` fields in changed plans, checkpoints, and bug-fix records before opening broader
specs.
- Use `rg` on changed paths for `Changelog Impact`, `Fixed`, `Security`, `Operations`, `QA / Verification`, and
similar markers.
- Do not bulk-read the whole planning root, every checkpoint, or every decision.
6. Draft a changelog block from spec evidence, not from commit messages.
7. Update root `CHANGELOG.md` using the prepend-only contract below.
## What To Include
Include meaningful changes that affect users, operators, product behavior, domain rules, security posture, setup/env
requirements, QA expectations, defect outcomes, release checks, or agent/human handoff state.
Prefer explicit `Changelog Impact` fields when present in plans, checkpoints, and bug-fix records. Otherwise infer from
the changed specs and cite the source file.
Use these sections only when they have content:
- `Added`
- `Changed`
- `Fixed`
- `Removed`
- `Security`
- `Operations`
- `QA / Verification`
Each bullet should be concise, readable to a mixed internal audience, and link to the source spec record when possible.
Do not include:
- coordination-board churn
- template-only edits
- pure formatting
- internal refactors with no behavior, operation, QA, or release impact
- noisy status/date-only changes
- raw commit messages
## Prepend-Only Update Contract
`CHANGELOG.md` lives at the project root.
Use a dated block:
```markdown
## YYYY-MM-DD
```
If the user provides a version, milestone, or release label, use:
```markdown
## YYYY-MM-DD - <label>
```
When `CHANGELOG.md` does not exist, create it with:
```markdown
# Changelog
## YYYY-MM-DD
### Added
- ...
```
When `CHANGELOG.md` already exists:
- preserve all existing content exactly except for inserting the new block
- insert after the top `# Changelog` title and any short intro directly below it
- insert before the first existing `## ` changelog entry
- do not rewrite, reorder, deduplicate, merge, or clean up older entries unless the user explicitly asks
- when generating another chunk on the same date, create a new dated block instead of merging into the existing block
If there are no meaningful changelog entries, do not edit `CHANGELOG.md`; report that the spec diff had no
release-relevant changes.
## Final Response
Report:
- `CHANGELOG.md` created, updated, or left unchanged
- comparison range used
- planning root used
- number of entries added by section
- any source specs that looked relevant but were intentionally omitted as noise
- checks run, if any
Keep the response concise. Do not paste the full changelog unless the user asks.
-1
View File
@@ -1 +0,0 @@
linear-docs/
-155
View File
@@ -1,155 +0,0 @@
# Cowork Guide
This guide defines the project workflow for AI and human development sessions.
## Planning Root
The workbench root is `workbench/`.
- Durable product truth lives in `workbench/blueprint/`.
- Active execution records live in `workbench/cowork/`.
- Research notes, prototype references, and screenshots live in `workbench/research/`.
- Ignored local-only data lives in `workbench/local/`.
- Ignored scratch notes live in `workbench/scratch/`.
## Token-Efficient Context
Before substantial implementation work, gather context in this order:
1. Read `AGENTS.md`, this guide, and `workbench/README.md`.
2. Use indexes first: `workbench/blueprint/EPICS.md`, `workbench/blueprint/MILESTONES.md`, and `workbench/cowork/COORDINATOR.md`.
3. Open only the relevant milestone, plan, bug-fix, decision, business-rule, check, manual QA, setup, env, security, or guideline sections.
4. Use `rg` for terms, paths, statuses, and links before opening large files.
5. Avoid reading full directories or long historical records unless the current task depends on them.
Keep outputs compact: report changed records, blockers, validation results, and next action. Do not paste full workbench documents or verbose checkpoint narratives unless explicitly requested.
## Status Vocabulary
Use these statuses unless a local file defines a narrower vocabulary:
- `Proposed`
- `Ready`
- `Active`
- `Blocked`
- `Done`
- `Dropped`
Decision records may also use `Accepted` or `Superseded by NNN` because they track durable choices rather than
execution state.
## Work Types
- Product truth belongs in `workbench/blueprint/PRODUCT.md`.
- Current domain behavior belongs in `workbench/blueprint/business-rules/`.
- Durable rationale belongs in `workbench/blueprint/decisions/`.
- Roadmap sequencing belongs in `workbench/blueprint/MILESTONES.md` and `workbench/blueprint/milestones/`.
- Implementation plans belong in `workbench/cowork/plans/`.
- Phase completion notes belong in `workbench/cowork/checkpoints/`.
- Scoped defects belong in `workbench/cowork/bug-fixes/`.
- Active parallel work belongs in `workbench/cowork/COORDINATOR.md`.
- Automated verification belongs in `workbench/blueprint/CHECKS.md`.
- Manual verification belongs in `workbench/blueprint/MANUAL_QA.md`.
## Blueprint Human Ownership
Documents in `workbench/blueprint/` are human-owned product truth. AI agents may help refine wording, improve structure, connect references, and turn a human-written prompt or partial draft into a more formal document, but the concepts must originate from humans and the final document must be reviewed by humans.
Agents must not invent product direction, business rules, milestones, architecture decisions, security posture, env expectations, checks, manual QA, or design guidance for `workbench/blueprint/`. If a blueprint update lacks human-provided source material or review, mark the uncertain parts as `TBD` and ask for human input.
## Review Gates
Milestones, plans, bug fixes, decisions, business rules, and checkpoints end with:
```markdown
- [ ] Reviewed and refined by: TBD
```
Before starting implementation, the relevant plan must have a completed human review line, for example `- [ ] Reviewed and refined by: Javier`. Before creating plans or implementing work under a milestone, the milestone must have the same completed review line. Before implementing a bug fix, the bug-fix record must be reviewed. If the line is missing or still `TBD`, stop and ask for human review instead of proceeding.
## Workbench Validation
Run the validator after editing workbench records, workflow templates, workflow skills, or `AGENTS.md` workbench instructions:
```bash
node workbench/scripts/validate-workbench.mjs workbench
```
From inside `workbench/`, run:
```bash
node scripts/validate-workbench.mjs .
```
Treat failures as blockers before handing work back. Report warnings when they affect the current task, especially missing review gates, broken links, required section gaps, stale paths, or unresolved placeholders.
## Plan Workflow
Create or update a plan when work is multi-step, cross-package, user-visible, risky, or needs durable handoff.
Plans should include:
- Goal
- Definition of done
- Out of scope
- Phases
- Validation
- Manual QA impact
- Changelog impact
- Circuit breakers
- Risks
- Decision-record check
## Checkpoint Workflow
Create a checkpoint after each meaningful plan phase only when phases were actually executed as separate handoff points. If an agent implements a plan end to end in one continuous pass, create one concise checkpoint for the completed plan instead of verbose phase-by-phase checkpoints.
Checkpoints should capture:
- Completed date
- Summary
- Files changed
- Checks run
- Notes and surprises
- Implications for product docs, business rules, decisions, changelog, and future plans
- Follow-ups
## Circuit Breakers
Stop and ask for direction when:
- The same check fails repeatedly without new evidence.
- Required verification cannot run.
- Scope expands beyond the current plan.
- Requirements conflict.
- A security or data-loss risk appears.
- A migration or deployment step is unclear.
## Documentation Sync
Update the workbench when code changes alter:
- Product behavior
- Business rules
- Architecture decisions
- Env vars
- Setup
- Security posture
- Automated checks
- Manual QA expectations
- Release-visible behavior
## Changelog Impact
Plans, checkpoints, and bug-fix records should identify changelog impact with one category:
- `Added`
- `Changed`
- `Fixed`
- `Removed`
- `Security`
- `Operations`
- `QA / Verification`
- `None`
Use `None` for internal-only implementation work, coordination churn, formatting, template updates, or changes with no release communication value.
-33
View File
@@ -1,33 +0,0 @@
# Workbench
The workbench is the project work surface for durable product truth, execution records, reusable prompts, helper scripts, scratch notes, and local-only data.
## Agent Quickstart
1. Read `AGENTS.md`, then `workbench/GUIDE.md`.
2. Use indexes and `rg` to find the relevant `workbench/blueprint/` truth before planning or implementation.
3. Use reviewed `workbench/cowork/` plans, bug fixes, and checkpoints for execution context.
4. Stop before planning or implementation if the relevant milestone, plan, or bug-fix record lacks a completed human review gate.
5. After editing workbench records or workflow instructions, run `node workbench/scripts/validate-workbench.mjs workbench`.
## Directory Map
```text
workbench/
GUIDE.md
blueprint/
cowork/
local/
research/
scratch/
scripts/
```
- `blueprint/`: stable product and application truth.
- `cowork/`: active AI and human development workflow.
- `blueprint/guidelines/`: guidelines for AI and human development. It expands `AGENTS.md` with concrete rules and best practices.
- `cowork/prompts/`: reusable prompts and handoff text.
- `research/`: research notes, reference screenshots, diagrams, and prototype logic.
- `local/`: ignored local data such as Docker-mounted database directories.
- `scratch/`: ignored temporary thinking space.
- `scripts/`: helper scripts for maintaining the local development environment and the workbench.
-97
View File
@@ -1,97 +0,0 @@
# Checks
Automated verification commands for the Formbricks monorepo. The canonical scripts live in the root [`package.json`](../../package.json) and per-app `package.json` files; everything below maps to those scripts exactly. If a script name doesn't match what's on disk, the disk is correct — update this file.
## Environments Covered
- **Local development**`pnpm dev` against `pnpm db:up` (Docker Postgres + Valkey + Hub + Cube).
- **Tests** — Vitest (unit/integration) + Playwright (E2E) against the same docker stack.
- **CI** — GitHub Actions runs lint + typecheck + test + build per package via Turborepo.
- **Preview** — branch deploys mirror production env shape, with separate Postgres + Redis + Hub + Cube.
- **Production** — Formbricks Cloud (`IS_FORMBRICKS_CLOUD=1`) and self-hosted (`ghcr.io/formbricks/formbricks:latest`).
- **Self-hosting / containers**`docker/docker-compose.yml` + `helm-chart/`. Required services: Postgres (`pgvector/pgvector:pg18`), Valkey, Hub, Cube, optional S3-compatible store (RustFS by default).
## Required Services for Local Verification
Most checks require the local stack to be up. Start it before running anything below:
```sh
pnpm db:up # docker compose -f docker-compose.dev.yml up -d
pnpm dev:setup # generates ENCRYPTION_KEY / NEXTAUTH_SECRET / CRON_SECRET / CUBEJS_API_SECRET into .env
pnpm db:migrate:dev # apply Prisma migrations against the dev DB
```
Stop with `pnpm db:down`. See `ENV_VARS.md` for the required keys; the `dev:setup` script generates them.
## Canonical Commands
| Command | Purpose | When To Run | Expected Signal | Notes |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm install` | Install all workspace deps from `pnpm-lock.yaml`. | First clone, after lockfile changes. | Exits 0; `node_modules/` populated. | Locked via `packageManager: pnpm@10.32.1`. Do not use `npm` or `yarn`. |
| `pnpm db:up` / `pnpm db:down` | Start / stop the Docker stack backing dev (Postgres + Valkey + Hub + Cube). | Before any task that hits the DB / Redis / Hub / Cube. | Containers reach `healthy` state. | Compose file: `docker-compose.dev.yml`. |
| `pnpm dev:setup` | Generate required secrets into `.env` from `.env.example`. | Once per fresh clone. | `.env` exists with `ENCRYPTION_KEY` / `NEXTAUTH_SECRET` / `CRON_SECRET` / `CUBEJS_API_SECRET` populated. | Implemented by [`scripts/setup-dev-env.sh`](../../scripts/setup-dev-env.sh). Idempotent. |
| `pnpm dev` | Run all apps and workers in dev mode in parallel (Turbo `--parallel`). | Local development. | Next.js (`apps/web`) live on `http://localhost:3000`; survey + js-core build watchers running. | Web uses `next dev --turbopack`. Hot-reload doesn't cover `packages/surveys` — see "Survey runtime cache" below. |
| `pnpm go` | Bring up the DB stack and start every package's `go` task (typically `vite --watch` for libraries). | When iterating on `packages/surveys`, `survey-ui`, `js-core` alongside the app. | Same as `dev`, plus library watchers. | Runs at `--concurrency 20` via Turbo. |
| `pnpm build` | Production build for every package and app. | Before release, before CI checks, before final review. | All `build` tasks exit 0; `dist/` and `.next/` populated. | Web build runs with `NODE_OPTIONS=--max-old-space-size=8192`. |
| `pnpm build --filter=@formbricks/surveys... --force` | Force-rebuild the survey runtime and its dependencies, bypassing Turbo cache. | After touching `packages/surveys` / `survey-ui` / `types`. | Fresh `surveys.umd.cjs` in `apps/web/public/js/`. | Required — see "Survey runtime cache" below. Clear `node_modules/.cache/turbo` first if Turbo replays a stale cache. |
| `pnpm db:migrate:dev` | Apply Prisma migrations against the dev DB. | After pulling, after editing `schema.prisma`. | Migration log ends with "All migrations applied" + Prisma client regenerated. | Wraps `prisma generate` + the in-house migration runner. |
| `pnpm db:migrate:deploy` | Apply pending migrations against a production-shaped DB. | Deploys. | Migration log ends with "All migrations applied". | Uses `MIGRATE_DATABASE_URL` if set; otherwise `DATABASE_URL`. |
| `pnpm db:push` | Sync the Prisma schema to the DB without creating a migration. | Spike work only — never production. | `prisma db push` exits 0. | Runs with `--accept-data-loss`. Treat as destructive. |
| `pnpm db:seed` / `pnpm db:seed:clear` | Seed (or clear) dev data. | After resetting the dev DB. | Seed script exits 0. | Implemented in `packages/database/src/seed.ts`. |
| `pnpm lint` | ESLint over the workspace via Turbo. | Before final review and in CI. | Exits 0; auto-fixes applied by `--fix`. | Shared preset: [`@formbricks/eslint-config`](../../packages/config-eslint/). |
| `pnpm typecheck` | TypeScript `--noEmit` over the workspace. | After TS changes; in CI. | Exits 0. | The web app uses a dedicated `tsconfig.typecheck.json` and runs `next typegen` first. |
| `pnpm test` | Vitest unit + integration suites across the workspace (Turbo, `--no-cache`). | After behavior changes; before final review. | Vitest exits 0. | Web tests are loaded with `dotenv -e ../../.env`. Tests for `.tsx` files are intentionally excluded — see Playwright instead. |
| `pnpm test:coverage` | Vitest with `@vitest/coverage-v8`. | When touching critical flows or measuring drift. | Coverage report generated; no regression. | Same env wiring as `pnpm test`. |
| `pnpm test:e2e` | Playwright E2E suite against a running app. | Before merging UI changes; in CI on the slow lane. | Playwright exits 0; report in `playwright-report/`. | Specs live in [`apps/web/playwright/`](../../apps/web/playwright/). Tag slow suites `@slow`. |
| `pnpm test-e2e:azure` | Playwright in the Azure-hosted browser service with 10 parallel workers. | Cloud CI runs. | Same as `test:e2e`. | Uses `playwright.service.config.ts`. |
| `pnpm format` | Format `.ts`/`.tsx`/`.md` files via Prettier (110-char width, semicolons, double quotes). | After code/docs edits; before final review. | Files rewritten as needed; pre-commit hook reformats staged files. | The `lint-staged` hook (`husky` + `lint-staged`) runs Prettier on staged files automatically. The `oxfmt` migration is a follow-up. |
| `pnpm storybook` | Run the Storybook dev server for the component library. | When reviewing UI changes in isolation. | Storybook on `http://localhost:6006`. | Lives in [`apps/storybook`](../../apps/storybook/). |
| `pnpm i18n` | Generate missing translations (via lingo.dev) and scan for unused keys. | After adding `t("…")` keys. | Exits 0; locale files updated. | Wraps `pnpm i18n:web:generate` + `pnpm i18n:surveys:generate` + `pnpm scan-translations`. |
| `pnpm i18n:validate` | Scan for missing / unused translation keys without regenerating. | CI gate, when you don't want side effects. | Exits 0. | Implemented in [`packages/i18n-utils`](../../packages/i18n-utils/). |
| `pnpm --filter @formbricks/web generate-api-specs` | Regenerate OpenAPI specs from zod schemas (`zod-openapi`). | After API surface changes. | Updated `openapi.yml` + client endpoint files. | Followed by `pnpm --filter @formbricks/web merge-client-endpoints` to fold in client-API routes. |
| `pnpm clean` | Remove `.turbo`, `node_modules`, `coverage`, `out` across the workspace. | When Turbo cache is suspected stale. | Filesystem reclaimed. | Use `pnpm clean:all` to additionally remove `pnpm-lock.yaml`. |
## Survey Runtime Cache (the gotcha)
The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the built bundle is copied to `apps/web/public/js/`. The Next.js app imports from `dist/`, **not** the source files. After changing `packages/surveys`, `packages/survey-ui`, or `packages/types`:
```sh
rm -rf packages/surveys/dist apps/web/public/js/surveys.* node_modules/.cache/turbo
pnpm build --filter=@formbricks/surveys... --force
```
Then hard-refresh the browser (Cmd+Shift+R / Ctrl+Shift+R) to bust the served `surveys.umd.cjs`. If the change still doesn't appear, restart `pnpm dev`. This is the single most common "why isn't my edit showing up" failure mode in the repo.
## Quick Check Recipes
- **Before opening a PR (light):** `pnpm lint && pnpm typecheck && pnpm test`.
- **Before opening a PR (UI-touching):** the light pass + `pnpm test:e2e` for the affected area + visual check via Storybook / `pnpm dev`.
- **Before opening a PR (schema-touching):** `pnpm db:migrate:dev && pnpm db:seed && pnpm typecheck && pnpm test`. Include the migration file diff in the PR.
- **Before opening a PR (API surface):** add `pnpm --filter @formbricks/web generate-api-specs` and commit the updated `openapi.yml`.
- **Before deploying:** `pnpm build` clean, `pnpm db:migrate:deploy` against the target DB.
- **Workflows PoC slice:** `pnpm --filter @formbricks/database generate`, `pnpm --filter @formbricks/jobs build`,
`pnpm --filter @formbricks/jobs exec vitest run src/queue.test.ts src/processors.test.ts`,
`pnpm --filter @formbricks/web exec vitest run app/api/v3/workflows/route.test.ts modules/workflows/lib/workflows-schema.test.ts modules/workflows/lib/executor.test.ts modules/workflows/lib/service.test.ts instrumentation-jobs.test.ts`,
`pnpm --filter @formbricks/web typecheck`, and `pnpm i18n:validate`. OpenAPI generation stays
deferred for the PoC per [Decision 003](./decisions/003-workflows-mvp-is-proof-of-concept.md).
## Circuit Breakers
Stop and document the blocker when:
- The same command fails repeatedly with no new evidence — root-cause the failure instead of retrying.
- Required services are unavailable (`pnpm db:up` fails, Hub container won't start, Cube can't reach Postgres). Diagnose the compose stack before continuing.
- Required env vars are missing — `pnpm dev:setup` solves the common case; missing `HUB_API_KEY`, `CUBEJS_API_SECRET`, OAuth credentials, or `STRIPE_*` for the touched flow needs the user.
- A Prisma migration cannot be safely run (data drift, irreversible step). Snapshot the DB, write a down-script, or ask before pressing forward.
- Tests fail intermittently — they're either a real race or a real bug. Don't reroll the dice.
- Verification requires credentials or infrastructure that aren't available (OAuth keys, Stripe webhook secret, SMTP server, Sentry DSN). Note the gap; don't hand-roll fakes.
## Maintenance
Update this file when:
- A new `pnpm` script is added at the root or in `apps/web`.
- A required service is added to the dev compose file.
- A check changes its name, its expected output, or its required env vars.
- The Turbo pipeline changes for any package's `build` / `test` / `lint` / `typecheck` task.
- The Prettier vs `oxfmt` story flips (see `decisions/000-baseline.md` follow-ups).
-39
View File
@@ -1,39 +0,0 @@
# Design
This file is the index of design guidelines for the Formbricks codebase. Each surface that has its own visual and
interaction language gets its own opinionated guide. This page is the table of contents — not the place to put new
rules.
## Guides
- **[Survey Runtime](./guidelines/design-guidelines-survey-runtime.md)** — the design system for the embedded
survey runtime that end-users see when they answer a Formbricks survey. Covers `@formbricks/surveys` (Preact host)
and `@formbricks/survey-ui` (React 19 component library), the `--fb-*` themeable token layer, the stacked-card
"Quiet Stage" model, and the accessibility and embedding contract.
- **[Survey Runtime Components Guide](./guidelines/components-guide-survey-runtime.md)** — companion catalog to
the runtime design guide. Field guide to the components in `@formbricks/surveys` and `@formbricks/survey-ui`
the public render API, the survey-level shell, the stacked-card stage, the question-type renderers, the input
primitives, and the internal lib. Skips the bare Radix primitives.
- **[Dashboard (apps/web)](./guidelines/design-guidelines-dashboard.md)** — the design system for the Formbricks
web app: workspace shell, settings pages, the survey editor, response analysis, and everything else inside
`apps/web`. Covers the "Workshop" theme, the slate-with-teal-accent palette, the page skeleton pattern
(`PageContentWrapper` + `PageHeader` + `SecondaryNavigation` + `SettingsCard`), and the elevation / typography
/ accessibility contracts.
- **[Dashboard Components Guide](./guidelines/components-guide-dashboard.md)** — companion catalog to the dashboard
design guide. Field guide to the higher-level Formbricks-specific components in `apps/web` (app shell, page
primitives, modals, alerts, forms, tables, the survey editor surface, segments, integrations). Skips the bare
shadcn primitives — when you need a generic primitive, the upstream shadcn docs apply.
- **[Workflows Builder](./guidelines/design-guidelines-workflows.md)** — design blueprint for the Workflows feature
inside `apps/web`: the React Flow canvas, node anatomy, the category color system (Trigger, Flow, Data, Core,
Compute, AI, Human Input), the collapsible config drawer, list and run pages, status pills, and the explicit
reuse map against the dashboard catalog plus the small set of net-new components needed.
## Conventions
- One design guide per surface. The runtime guide does not govern the dashboard, and vice versa. If a rule applies
to both, it belongs in a shared section (added explicitly here) — not duplicated across guides.
- Guides are opinionated. Each one carries a creative north star, a token system, and concrete component rules.
Vague guidelines get ignored; specific ones shape the product.
- Component catalogs sit next to their design guide and link back to it. Catalogs are inventories, not rule books —
the rules they enforce live in the design guide.
- This index stays short. Add a new line here when a new guide ships; keep detail in the guide itself.
-180
View File
@@ -1,180 +0,0 @@
# Environment Variables
Operator-facing and developer-facing environment variables for the Formbricks monorepo. This file is the workbench-side index — the **canonical, exhaustive reference is the self-hosting doc** at [`docs/self-hosting/configuration/environment-variables.mdx`](../../docs/self-hosting/configuration/environment-variables.mdx). Cross-check there when wiring or deploying.
## Coverage
- **Local development** — generated by [`scripts/setup-dev-env.sh`](../../scripts/setup-dev-env.sh) from [`.env.example`](../../.env.example) into `.env`. Runs against the Docker stack from `docker-compose.dev.yml`.
- **Tests** — read `../../.env` via `dotenv -e ../../.env` (Vitest scripts in `apps/web/package.json`).
- **CI / preview / production** — set via the deployment platform; the same names apply.
- **Self-host (Docker / Helm)**`docker/docker-compose.yml` and `helm-chart/values.yaml` declare the same set under `x-environment`.
- **Formbricks Cloud** — adds `IS_FORMBRICKS_CLOUD=1` and the Stripe / PostHog / Chatwoot / Sentry stack.
Never commit real values. `pnpm dev:setup` generates 32-byte hex secrets via `openssl rand -hex 32` for the four required ones (`ENCRYPTION_KEY`, `NEXTAUTH_SECRET`, `CRON_SECRET`, `CUBEJS_API_SECRET`). The Docker compose file uses `:?` to fail fast when a required secret is unset.
## Core (Required for any deployment)
| Variable | Required In Production | Default | Description | Security Notes |
| ----------------- | ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| `WEBAPP_URL` | Yes | `http://localhost:3000` | Base URL of the dashboard. Used for absolute links, OAuth callbacks, email links. | Public. |
| `PUBLIC_URL` | No | falls back to `WEBAPP_URL` | Base URL where surveys and public content are served. Use when the public domain differs from the app. | Public. |
| `NEXTAUTH_URL` | Yes | `http://localhost:3000` | NextAuth callback base; usually equals `WEBAPP_URL`. With a sub-path, set to `…/api/auth`. | Public. |
| `NEXTAUTH_SECRET` | Yes | (none) | Session signing/encryption secret. **32 bytes max.** Generate: `openssl rand -hex 32`. | Treat as secret. Rotate triggers signout. |
| `ENCRYPTION_KEY` | Yes | (none) | App-level encryption + audit-log hashing. **32 bytes max.** Generate: `openssl rand -hex 32`. | Treat as secret. Loss breaks decryption. |
| `CRON_SECRET` | Yes | (none) | Auth header for cron endpoints. **32 bytes max.** Generate: `openssl rand -hex 32`. | Treat as secret. |
| `DATABASE_URL` | Yes | (none) | PostgreSQL (pgvector) connection string. Format: `postgresql://user:pass@host:5432/db?schema=public`. | Treat as secret. Never log. |
| `REDIS_URL` | Yes | (none) / `redis://redis:6379` | Redis / Valkey URL. Used for caching, rate limiting, audit log buffer, BullMQ. | Treat as secret if remote. |
| `LOG_LEVEL` | No | `info` | `debug` / `info` / `warn` / `error` / `fatal`. | — |
## Hub & Cube (Required for v5+)
Formbricks v5 makes the Hub service part of the standard runtime, and the analytics surface (Charts/Dashboards) requires Cube.
| Variable | Required In Production | Default | Description | Security Notes |
| --------------------- | ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- | ---------------- |
| `HUB_API_KEY` | Yes | (none, dev: `dev-api-key`) | Shared secret between the web app and the Hub service. Must match on both sides. | Treat as secret. |
| `HUB_API_URL` | Yes | `http://hub:8080` | Base URL the web app uses to reach Hub. Internal compose service in self-host; external URL in cloud. | Public. |
| `HUB_DATABASE_URL` | No | (falls back to `DATABASE_URL`) | Separate Postgres for Hub. Default reuses the Formbricks DB with `sslmode=disable`. | Treat as secret. |
| `CUBEJS_API_URL` | Yes | `http://cube:4000` | URL of the Cube semantic-layer service. | Public. |
| `CUBEJS_API_SECRET` | Yes | (none) | Shared secret for Cube JWT verification. Generate: `openssl rand -hex 32`. | Treat as secret. |
| `CUBEJS_JWT_ISSUER` | No | `formbricks-web` | JWT `iss` claim emitted by Formbricks. | — |
| `CUBEJS_JWT_AUDIENCE` | No | `formbricks-cube` | JWT `aud` claim Cube verifies. | — |
| `CUBEJS_DB_*` | Yes (Cube container) | `postgres` defaults | Cube's connection to the analytics DB (`DB_HOST`/`NAME`/`USER`/`PASS`/`PORT`). Set on the Cube service. | Treat as secret. |
## Auth — Providers (Optional, one or more)
| Variable | Description | Security Notes |
| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------- |
| `EMAIL_AUTH_DISABLED` | Disable email/password sign-in (only OAuth/SSO remains). | — |
| `EMAIL_VERIFICATION_DISABLED` | Skip the email-verification step on signup. | Lowers signup friction; not for prod. |
| `PASSWORD_RESET_DISABLED` | Hide the password-reset flow. | — |
| `INVITE_DISABLED` | Block new invitations. | — |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google OAuth credentials. | Treat secret as secret. |
| `GITHUB_ID` / `GITHUB_SECRET` | GitHub OAuth credentials. | Treat secret as secret. |
| `AZUREAD_CLIENT_ID` / `AZUREAD_CLIENT_SECRET` / `AZUREAD_TENANT_ID` | Azure AD OAuth credentials. | Treat secrets as secret. |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` / `OIDC_ISSUER` / `OIDC_DISPLAY_NAME` / `OIDC_SIGNING_ALGORITHM` | Generic OIDC provider (EE). | Treat secrets as secret. |
| `SAML_DATABASE_URL` | Separate DB for `@boxyhq/saml-jackson` (EE). | Treat as secret. |
| `AUTH_SKIP_INVITE_FOR_SSO` | When set, SSO users auto-join. | Security-sensitive. |
| `AUTH_SSO_DEFAULT_TEAM_ID` | Default team for new SSO members (EE). | — |
| `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION` | Skip confirmation step on SSO account deletion. | Security-sensitive. |
| `SESSION_MAX_AGE` | Session lifetime in seconds. | — |
| `PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` | Reset-token TTL. | — |
## Email (Required for invites / follow-ups)
| Variable | Description | Security Notes |
| --------------------------------------------------------- | --------------------------------------------- | ------------------------- |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | SMTP server credentials used by `nodemailer`. | Treat password as secret. |
| `SMTP_SECURE_ENABLED` | `1` to enable TLS. | — |
| `SMTP_AUTHENTICATED` | `1` if the SMTP server requires auth. | — |
| `SMTP_REJECT_UNAUTHORIZED_TLS` | `0` to accept self-signed certs (dev only). | Do not unset in prod. |
| `MAIL_FROM` | Sender address. | — |
| `MAIL_FROM_NAME` | Sender display name. | — |
## Storage — S3 / S3-Compatible (Optional)
Setting `S3_BUCKET_NAME` enables S3-backed storage for uploads. Without it, Formbricks falls back to local FS (not suitable for production).
| Variable | Description | Security Notes |
| --------------------------------- | ------------------------------------------------------------------- | ---------------- |
| `S3_BUCKET_NAME` | S3 bucket name. Setting this enables S3 storage. | — |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | Static credentials. Omit to use the AWS SDK chain (IAM role / env). | Treat as secret. |
| `S3_REGION` | AWS region. | — |
| `S3_ENDPOINT_URL` | Custom endpoint (MinIO, RustFS, LocalStack). | — |
| `S3_FORCE_PATH_STYLE` | `1` for S3-compatible stores that require path-style URLs. | — |
## Integrations (Optional, per integration)
| Variable | Integration |
| ---------------------------------------------------------------------------------------- | --------------------- |
| `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` | Slack |
| `NOTION_OAUTH_CLIENT_ID` / `NOTION_OAUTH_CLIENT_SECRET` | Notion |
| `GOOGLE_SHEETS_CLIENT_ID` / `GOOGLE_SHEETS_CLIENT_SECRET` / `GOOGLE_SHEETS_REDIRECT_URL` | Google Sheets |
| `AIRTABLE_CLIENT_ID` | Airtable |
| `UNSPLASH_ACCESS_KEY` | Unsplash media picker |
Treat every `*_SECRET` / `*_CLIENT_SECRET` as secret.
## AI (Optional)
| Variable | Description |
| --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `AI_PROVIDER` | `bedrock` / `azure` / `google` (Vertex via `@ai-sdk/google-vertex`). |
| `AI_MODEL` | Provider-specific model ID (e.g. `gemini-2.5-flash`). |
| `AI_AWS_ACCESS_KEY_ID` / `AI_AWS_SECRET_ACCESS_KEY` / `AI_AWS_REGION` / `AI_AWS_SESSION_TOKEN` | Bedrock credentials. |
| `AI_AZURE_API_KEY` / `AI_AZURE_API_VERSION` / `AI_AZURE_BASE_URL` / `AI_AZURE_RESOURCE_NAME` | Azure OpenAI. |
| `AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS` / `AI_GOOGLE_CLOUD_CREDENTIALS_JSON` / `AI_GOOGLE_CLOUD_LOCATION` / `AI_GOOGLE_CLOUD_PROJECT` | Google Vertex / Gemini. |
Hub embeddings are configured separately on the Hub service: `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, `EMBEDDING_PROVIDER_API_KEY`.
## Spam Protection (Optional)
| Variable | Description |
| --------------------------------------------- | ---------------------------------------------------- |
| `RECAPTCHA_SITE_KEY` / `RECAPTCHA_SECRET_KEY` | Google reCAPTCHA v3 keys for survey spam protection. |
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile keys (alternative). |
## Observability (Optional)
| Variable | Description |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `SENTRY_DSN` | Sentry project DSN. |
| `SENTRY_ENVIRONMENT` | Sentry environment tag (e.g., `production`, `preview`). |
| `SENTRY_AUTH_TOKEN` | Used by `@sentry/nextjs` at build time to upload source maps. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL. |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` or `grpc`. |
| `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` / `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` | Standard OTel knobs. |
| `PROMETHEUS_ENABLED` / `PROMETHEUS_EXPORTER_PORT` | Enable + port for the Prometheus exporter. |
| `AUDIT_LOG_ENABLED` | `1` to enable the EE audit log. |
| `AUDIT_LOG_GET_USER_IP` | `1` to include user IP in audit entries. |
| `TELEMETRY_DISABLED` | `1` to opt out of usage telemetry. |
## Billing (Cloud Only)
| Variable | Description | Security Notes |
| ------------------------ | ---------------------------- | ---------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret. | Treat as secret. |
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key. | Public. |
| `STRIPE_WEBHOOK_SECRET` | Signs Stripe webhook events. | Treat as secret. |
## Job Queue (BullMQ Workers)
| Variable | Description |
| -------------------------------- | ---------------------------------------------------------------------------- |
| `BULLMQ_WORKER_ENABLED` | `1` (default outside tests) to spin up BullMQ workers in-process. |
| `BULLMQ_EXTERNAL_WORKER_ENABLED` | `1` when a separate worker deployment consumes jobs (web pods only enqueue). |
| `BULLMQ_WORKER_COUNT` | Number of worker instances per Formbricks server process. Default `1`. |
| `BULLMQ_WORKER_CONCURRENCY` | Jobs each worker processes concurrently. Default `1`. |
## Build-Time / Runtime Knobs
| Variable | Description |
| ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `BASE_PATH` | Deploy under a sub-path (build-time only). |
| `DEFAULT_BRAND_COLOR` | Default brand color for new workspaces. |
| `DEFAULT_ORGANIZATION_ID` | Single-tenant lockdown — every signup joins this org. |
| `USER_MANAGEMENT_MINIMUM_ROLE` | Minimum role allowed to manage other users (EE). |
| `RATE_LIMITING_DISABLED` | `1` to disable rate limiting (dev only). |
| `DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS` | `1` to allow webhooks to target internal IPs. **Self-host only; never enable on cloud.** |
| `HTTP_PROXY` / `HTTPS_PROXY` | Outbound proxy for the web/worker process. |
| `IMPRINT_URL` / `IMPRINT_ADDRESS` / `PRIVACY_URL` / `TERMS_URL` | Legal links rendered in the footer. |
| `NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE` / `NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR` / `NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE` | Survey publish/close scheduling defaults (build-time public). |
| `LINGO_API_KEY` | Used by `pnpm i18n:*:generate` (lingo.dev CLI). |
| `ENTERPRISE_LICENSE_KEY` | Activates Enterprise Edition features (self-host). |
## Internal Tooling Variables
| Variable | Set By | Description |
| ---------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------- |
| `IS_FORMBRICKS_CLOUD` | Cloud build pipeline | Toggles the cloud-only entitlement / billing / Chatwoot wiring. |
| `MIGRATE_DATABASE_URL` | CI / deploy | If set, used by `db:migrate:deploy` instead of `DATABASE_URL`. |
| `HUB_IMAGE_TAG` | `docker-compose.dev.yml` | Pins the Hub image when testing a specific release. |
| `CHATWOOT_BASE_URL` / `CHATWOOT_WEBSITE_TOKEN` | Cloud env | Cloud-only customer-support widget. |
| `POSTHOG_KEY` | Cloud / observability | Toggles in-app PostHog analytics + feature flags. Absence disables product analytics. |
## Notes
- **Placeholder values only.** Never paste real secrets in this file, in commits, in PR descriptions, in scratch notes, or in chat. The repo's `.gitignore` excludes `.env`; nothing else is protected.
- **Length limits.** `NEXTAUTH_SECRET`, `ENCRYPTION_KEY`, `CRON_SECRET`, and `CUBEJS_API_SECRET` are capped at 32 bytes. `openssl rand -hex 32` produces a 64-character hex string of exactly 32 bytes.
- **Rotation.** Rotating `NEXTAUTH_SECRET` invalidates every active session. Rotating `ENCRYPTION_KEY` breaks decryption of existing encrypted columns — do not rotate without a re-encryption migration.
- **Audit.** Variables added or removed here must also be reflected in [`docs/self-hosting/configuration/environment-variables.mdx`](../../docs/self-hosting/configuration/environment-variables.mdx) and `.env.example`.
-23
View File
@@ -1,23 +0,0 @@
# Epics
## Status Vocabulary
- `Proposed`: possible future work.
- `Active`: currently being planned or implemented.
- `Blocked`: waiting on a dependency or decision.
- `Done`: delivered and verified.
- `Dropped`: intentionally not moving forward.
## Current Focus
[E001 Workflows](./epics/E001-workflows.md)
## Epic Index
| Epic | Status | Summary | Related Work |
| ------------------------------------------- | -------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [E001 Workflows](./epics/E001-workflows.md) | Proposed | Introduce Trigger <> Condition <> Action workflows for XM action and automation. | [Milestone 001](./milestones/001-workflows-mvp.md), [Decision 001](./decisions/001-workflows-api-first-backend-contract.md), [Decision 002](./decisions/002-workflows-tech.md), [Business rules](./business-rules/001-workflows-glossary-and-scope.md) |
## Notes
Keep epics durable and product-facing. Detailed implementation sequencing belongs in `./milestones/` and `../cowork/plans/`.
-120
View File
@@ -1,120 +0,0 @@
# Manual QA
Manual verification flows that exist alongside the automated [`./CHECKS.md`](./CHECKS.md). Use these when:
- A change touches the survey runtime (`packages/surveys` / `packages/survey-ui`) — automated tests can't catch every visual / focus / RTL regression.
- A change touches the dashboard editor or the response analysis surface — the click depth is too high for unit tests.
- A change touches auth, billing, integrations, or any outbound side-effect we can't safely fake.
- A change touches the embedding contract (JS SDK, runtime mount, host-page CSS isolation).
`apps/web/playwright/` carries the automated E2E coverage; everything below is the human pass that complements it.
## Coverage
| Area | Status | Setup | Notes |
| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/web` — Dashboard | Maintained | `pnpm db:up && pnpm dev`. Sign in at `http://localhost:3000` with seeded user. | Desktop-only (mobile blocked by `NoMobileOverlay`). Verify in Chrome + at least one non-Chrome browser before releasing. |
| `packages/surveys` — Runtime | Maintained | Rebuild after every edit (see "Survey runtime cache" in `CHECKS.md`). Open the survey preview in the editor, or use a link survey URL. | Three modes to exercise: `modal`, `inline`, `link`. The bundled JS at `apps/web/public/js/surveys.umd.cjs` is what real customers ship. |
| `packages/survey-ui` — Component lib | Maintained | `pnpm storybook` (`http://localhost:6006`) and the editor preview pane. | Stories live next to the component in `*.stories.tsx`. Use them for keyboard / focus / RTL / locale variants. |
| `apps/storybook` — Shared stories | Light | `pnpm --filter @formbricks/storybook dev`. | Catches visual regressions for the dashboard component library. |
| `apps/web` — Workflows PoC | Light | `pnpm db:up && pnpm db:migrate:dev && pnpm dev`; use a seeded workspace/survey and worker queue. | Manual demo only for 001-010. Confirms v3-backed dashboard, Response Completed enqueue, no-send previews, and disabled workflow behavior. |
| `packages/js-core` — Embedded SDK | Maintained | Test against a real third-party host page (see "Embeddability" workflow below). | The SDK is the moat; broken embedding breaks every customer integration silently. |
| `docker/docker-compose.yml` — Self-host stack | Light | `docker compose -f docker/docker-compose.yml up`. Smoke-test the same web flows. | Validate before any release; required env vars listed in `ENV_VARS.md`. |
## Workflows
The 10 must-pass paths. Each is "one human pass" — they're not exhaustive, but if all 10 pass, the build is shippable.
| # | Workflow | Persona | Environment | Expected Outcome | Failure Signals | Status |
| --- | -------------------------------------------- | --------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------- |
| 1 | **Sign up → onboarding → first survey** | New customer | Dashboard | Signup → email verification (or skip with `EMAIL_VERIFICATION_DISABLED=1` in dev) → onboarding wizard → first survey created with a template. | Auth callback errors; stuck onboarding; survey list empty after "Create". | Maintained |
| 2 | **Build a multi-question survey** | Survey creator | Dashboard editor | Add ≥3 question types (Open Text, Rating, NPS), add conditional logic, set a quota, save. Preview pane reflects every change. | Save fails; preview drifts from saved state; conditional logic editor doesn't render. | Maintained |
| 3 | **Answer a Link Survey** | End-respondent | `/s/<surveyId>` | Open the link URL, complete the survey to the end card. Submission appears in the dashboard responses table within seconds. | Runtime crash; question doesn't advance; submission missing or duplicated; CSS bleeding from host page. | Maintained |
| 4 | **Embed via JS SDK on a third-party page** | Developer | Test HTML page | Drop the `formbricks` snippet on a blank HTML page with a known `userId`/attributes, trigger via no-code or `formbricks.track("event")`. Survey appears. | Survey never appears; `formbricks is not defined`; CSP violation in console; SDK fails to load. | Maintained |
| 5 | **Identify a user + segment targeting** (EE) | Dev + analyst | Dashboard + SDK | `setUserId` + `setAttribute` from the SDK; the contact appears in `/contacts`; a Segment matches it; a Website & App survey targeted at the Segment fires. | Attributes don't sync; segment count stays 0; survey doesn't fire even when targeted user matches. | Maintained |
| 6 | **Invite a teammate + change role** | Org admin | Dashboard | Invite via email; accept; promote to Manager; deactivate. Access changes correctly at every step. | Invitee can't accept; role change doesn't propagate; deactivated user still sees data. | Maintained |
| 7 | **Configure an integration** | Workspace admin | Settings → Integrations | Connect Slack (or Google Sheets / Notion). Submit a test response; integration delivers it. | OAuth redirect loop; tokens not stored; test response not delivered. | Maintained |
| 8 | **Export & analyze** | Response analyst | Dashboard | Open a survey's analysis tab. View summary + responses table; tag a response; export CSV/xlsx; build a Chart on a Dashboard. | Charts fail to render (check Cube is up); export download empty; tag doesn't persist. | Maintained |
| 9 | **Multi-language survey** | Survey creator + respondent | Dashboard + survey | Add a non-default language to a survey; translate the headline; switch language in the runtime; submit a response. | Language switcher missing; translation doesn't apply; response language tag wrong. | Light |
| 10 | **Self-host smoke test** | Self-host operator | `docker compose up` | Bring up the full stack from `docker/docker-compose.yml`. Sign up + create + answer + analyze. | Container won't start; missing env var; Hub/Cube reachable failures; default port collisions. | Light |
## Survey-Runtime-Specific Checks
The runtime is high-leverage and easy to regress. After **any** change to `packages/surveys`, `packages/survey-ui`, or `packages/types`:
- **Rebuild + hard refresh.** See the "Survey runtime cache" section in `CHECKS.md` — the most common regression source is stale `surveys.umd.cjs`.
- **Stack vs simple arrangement.** Switch the survey's `cardArrangement` between `simple` and `straight` / `casual`. Both must render correctly.
- **Modal + inline + link modes.** Each is a separate code path in `survey-container.tsx`.
- **Placement.** `bottomRight` / `topRight` / `bottomLeft` / `topLeft` / `center` — exercise at least two on desktop.
- **Overlay.** `none` / `light` / `dark`. Click-outside should dismiss only when `clickOutside=true` and `overlay !== "none"`.
- **Keyboard nav.** Tab through every question type. Rating: `ArrowLeft` / `ArrowRight` + `Enter`. Submit: `Cmd/Ctrl+Enter`.
- **Focus ring.** Visible 3px brand-tinted ring on every interactive element. Never `outline: none` without a replacement.
- **RTL.** Set a language with `dir: "rtl"` (or pass `?lang=ar` on a link). Number rating scales must mirror correctly via `getRTLScaleOptionClasses`.
- **Themed brand.** Set a custom brand color in workspace styling; the runtime card should pick it up from `--fb-*` tokens. Borders, buttons, focus rings, progress fill all shift accordingly.
- **Host-page isolation.** Embed the survey on a page with a wild CSS reset / a dark theme / a heavy font override. The card must stay readable; the host page must stay unmodified.
- **Auto-close.** Run a modal survey with `autoClose` set. The progress bar shrinks; the modal closes; resetting on interaction works.
## Workflows PoC Demo
Use this script for [001-010 Workflows PoC vertical slice](../cowork/plans/001-010-workflows-poc-vertical-slice.md).
This is not the full Beta QA matrix.
1. Open the dashboard and navigate to the Workflows main sidebar section.
2. Create a new workflow from the dialog, enter a name and optional description, and confirm the
builder loads from the v3 API response with that metadata.
3. Configure the Response Completed trigger, the If/Else condition, Send Email Preview, and Send
Webhook Preview. Collapse and expand the node config drawer.
4. Save the draft and enable it.
5. Complete a matching survey response and confirm a workflow run is created in `queued`, then moves
through `running` to `completed`.
6. Open run detail and confirm the trigger payload, step outputs, email preview, and webhook preview
envelope are visible.
7. Open the workspace-level Workflow Runs page from the main navigation and confirm the same run is
visible with its workflow name, status, timestamp, response id, and detail link.
8. Navigate between the Workflows list, workspace Workflow Runs, builder, workflow runs, and run
detail pages and confirm route loading states use skeletons without dummy page-title text or large
layout shifts.
9. Disable the workflow, complete another matching response, and confirm no new run is created.
10. Confirm no real email is sent and no outbound webhook request is made.
Status: Passed on 2026-05-22 for the 001-010 implementation session. The local demo used the seeded
workspace and survey, created workflow `cmpga6fzg0009sbmcjw5mje79`, enabled it, enqueued only the
`workflow-run.process` BullMQ job for run `cmpgabuce0001sb60hnwj9l9a`, verified completed run
detail, and disabled the workflow. The full response pipeline hook is covered by automated tests;
the manual pass intentionally avoided a full `responseFinished` pipeline job because that can also
invoke legacy side effects.
## Setup Data
The dev seed (`pnpm db:seed`) creates a baseline. For deeper QA add the following manually:
- **One organization, two workspaces** — to verify cross-workspace isolation.
- **One survey of each type**`link`, `website`, `app`. With multi-language enabled on at least one.
- **One survey with logic + variables + recall** — exercises the conditional engine.
- **One survey with quotas + a segment** (EE) — exercises targeting + quota gating.
- **At least 50 responses across surveys** — for the analysis surface, charts, and pagination.
- **One contact CSV import** (EE) — exercises bulk attribute creation.
- **One configured integration** (Slack or webhook) — exercises outbound delivery.
- **One trial state + one downgraded state** (Cloud) — exercises `LimitsReachedBanner` + `PendingDowngradeBanner`.
`pnpm db:seed:clear` resets to a clean state. For larger fixtures, document the setup in a `cowork/plans/` doc rather than expanding this file.
## Devices and Browsers
- **Dashboard** — Desktop only. Primary: latest Chrome. Secondary: latest Firefox + latest Safari before release. Below `sm` (430px) the dashboard renders `NoMobileOverlay` by design.
- **Survey runtime** — Desktop + mobile. Primary: latest Chrome (desktop + Android). Secondary: latest Safari (desktop + iOS), latest Firefox. The runtime ships into customers' pages and must survive every realistic browser.
- **Email follow-ups** — Test the rendered HTML in at least Gmail (Chrome) and Apple Mail (macOS).
- **Locale matrix** — At minimum: `en-US`, one RTL locale (`ar`), one non-Latin (`ja` or `zh`). Lingo.dev auto-fills via `pnpm i18n` but the fonts and line-breaking still need a human eye.
## Update Rules
Add or update entries here when changes affect:
- **User-facing flows** — signup, survey creation, answering, response analysis, billing, integrations.
- **Roles, permissions, or auth** — any change to `getWorkspaceAuth`, `getAccessFlags`, the role hierarchy, or SSO config.
- **Embedded runtime contracts**`renderSurvey` / `renderSurveyInline` / `renderSurveyModal` props, the `formbricksSurveys` global, the CSS scoping rules.
- **Self-host setup data** — required env vars, ports, services.
- **Survey runtime visuals or interactions** — stack animation, theming tokens, keyboard nav, RTL behavior.
- **Release-critical behavior** — anything an upgrade-from-prior-version customer would notice.
Small internal refactors, copy tweaks, and dependency bumps don't need updates unless they change how a human should verify the result.
-24
View File
@@ -1,24 +0,0 @@
# Milestones
## Current Focus
| Field | Value |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Active milestone | [001 Workflows MVP](./milestones/001-workflows-mvp.md) |
| Recommended next plan | Review [001-010 Workflows PoC vertical slice](../cowork/checkpoints/001-010-workflows-poc-vertical-slice.md), then use [001-009 PoC feedback and beta iteration](../cowork/plans/001-009-mvp-feedback-and-beta-iteration.md) to choose Beta scope. |
| Latest checkpoint | [001-010 Workflows PoC vertical slice](../cowork/checkpoints/001-010-workflows-poc-vertical-slice.md) |
| Coordination board | [`COORDINATOR.md`](../cowork/COORDINATOR.md) |
## Milestone Index
| Number | Title | Status | Record | Summary |
| ------ | ------------- | -------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 001 | Workflows MVP | Proposed | [001-workflows-mvp.md](./milestones/001-workflows-mvp.md) | 001-010 PoC vertical slice is implemented; next step is human demo/review and Beta scope selection. |
## Execution Order
1. [001 Workflows MVP](./milestones/001-workflows-mvp.md)
## Notes
Detailed phase maps, risks, acceptance criteria, drafted plans, and checkpoint rollups belong in `milestones/`.

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