diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index 52d8cbbee8d5..3ed4312af999 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -171,7 +171,7 @@ describe("POST /api/mcp", () => { expect(response.status).toBe(401); expect(response.headers.get("Content-Type")).toBe("application/problem+json"); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp", scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' ); expect(applyIPRateLimit).toHaveBeenCalled(); }); @@ -445,7 +445,7 @@ describe("POST /api/mcp", () => { expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); expect(applyIPRateLimit).toHaveBeenCalled(); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp", scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' ); }); @@ -485,7 +485,9 @@ describe("POST /api/mcp", () => { expect(message.result.structuredContent.error).toMatchObject({ status: 403, code: "forbidden", - detail: "OAuth token does not include the required MCP scope", + // Names the scope this specific call needed, not just that some scope was missing — that is + // the only thing the client can act on. + detail: "OAuth token does not include the required MCP scope: surveys:write", requestId: "req_read_only", }); }); @@ -528,7 +530,9 @@ describe("POST /api/mcp", () => { expect(message.result.structuredContent.error).toMatchObject({ status: 403, code: "forbidden", - detail: "OAuth token does not include the required MCP scope", + // The refusal names the scope the client must obtain, since a JSON-RPC tool result carries no + // WWW-Authenticate header for it to read. + detail: "OAuth token does not include the required MCP scope: workflows:write", requestId: "req_wf_read_only", }); // The scope gate must fire BEFORE any mutation side effect: no audit log is built or queued for a diff --git a/apps/web/app/api/v3/workflows/lib/context.test.ts b/apps/web/app/api/v3/workflows/lib/context.test.ts index fbea286126f9..3143b43d91a0 100644 --- a/apps/web/app/api/v3/workflows/lib/context.test.ts +++ b/apps/web/app/api/v3/workflows/lib/context.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { TAuthenticationApiKey } from "@formbricks/types/auth"; import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; -import { getOrganizationMemberEmails } from "@/lib/organization/service"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { getWorkspaceMemberEmails } from "@/lib/workspace/service"; import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; import { buildWorkflowApiContext } from "./context"; @@ -16,7 +16,7 @@ vi.mock("@formbricks/logger", () => ({ })); vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: vi.fn() })); vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: vi.fn() })); -vi.mock("@/lib/organization/service", () => ({ getOrganizationMemberEmails: vi.fn() })); +vi.mock("@/lib/workspace/service", () => ({ getWorkspaceMemberEmails: vi.fn() })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsWorkflowsEnabled: vi.fn() })); const baseAuditLog = (): TV3AuditLog => ({ @@ -155,31 +155,48 @@ describe("verifyTriggerSurvey (validates a workflow trigger's referenced survey) }); }); -describe("verifyRecipientsAllowed (recipient allowlist for send_email, ENG-2029)", () => { - test("returns the literal recipients that are not organization members (case-insensitive)", async () => { - vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1"); - vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["member@corp.example"])); - - const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({ +describe("verifyRecipientsAllowed (recipient allowlist for send_email, ENG-2029 + ENG-2186)", () => { + const verifyRecipients = (emails: string[]) => + buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({ workspaceId: "ws_1", - emails: ["Member@corp.example", "attacker@external-evil.example"], + emails, }); - expect(getOrganizationMemberEmails).toHaveBeenCalledWith("org_1"); + test("returns the literal recipients that cannot access the workspace (case-insensitive)", async () => { + vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set(["member@corp.example"])); + + const result = await verifyRecipients(["Member@corp.example", "attacker@external-evil.example"]); + expect(result).toEqual({ disallowedEmails: ["attacker@external-evil.example"] }); }); - test("allows all recipients when each is an organization member", async () => { - vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1"); - vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["a@corp.example", "b@corp.example"])); + test("allows all recipients when each can access the workspace", async () => { + vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set(["a@corp.example", "b@corp.example"])); - const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({ - workspaceId: "ws_1", - emails: ["a@corp.example", "b@corp.example"], - }); + const result = await verifyRecipients(["a@corp.example", "b@corp.example"]); expect(result).toEqual({ disallowedEmails: [] }); }); + + test("scopes the allowlist to the workspace, not to its organization (ENG-2186)", async () => { + // The gate must ask who can access *this workspace*: an org member whose team lost access to it + // is rejected here, matching what the authoring picker offers and what the runner will send. + vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set(["still-has-access@corp.example"])); + + const result = await verifyRecipients(["revoked-member@corp.example"]); + + expect(getWorkspaceMemberEmails).toHaveBeenCalledWith("ws_1"); + expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled(); + expect(result).toEqual({ disallowedEmails: ["revoked-member@corp.example"] }); + }); + + test("rejects every literal recipient when the workspace resolves to nobody (fails closed)", async () => { + vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set()); + + await expect(verifyRecipients(["member@corp.example"])).resolves.toEqual({ + disallowedEmails: ["member@corp.example"], + }); + }); }); describe("recordAudit (binds the audit sink to the request's audit log)", () => { diff --git a/apps/web/app/api/v3/workflows/lib/context.ts b/apps/web/app/api/v3/workflows/lib/context.ts index 9c280243a658..fe923328dd38 100644 --- a/apps/web/app/api/v3/workflows/lib/context.ts +++ b/apps/web/app/api/v3/workflows/lib/context.ts @@ -11,9 +11,9 @@ import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; import { problemForbidden } from "@/app/api/v3/lib/response"; import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; import { ENCRYPTION_KEY } from "@/lib/constants"; -import { getOrganizationMemberEmails } from "@/lib/organization/service"; import { normalizeEmailForComparison } from "@/lib/utils/email"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { getWorkspaceMemberEmails } from "@/lib/workspace/service"; import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; /** @@ -94,16 +94,18 @@ const buildRecordAudit = /** * Recipient allowlist for `send_email` actions. Injected so `@formbricks/workflows` stays - * organization-agnostic: given literal recipient emails, returns the subset that does NOT belong to - * the workspace's organization. Enable/test use it to block a workflow from silently forwarding - * response data to an arbitrary external inbox (ENG-2029). Emails are compared case-insensitively. + * tenancy-agnostic: given literal recipient emails, returns the subset whose owners cannot access + * this workspace. Enable/test use it to block a workflow from silently forwarding response data to + * an arbitrary external inbox (ENG-2029) or to someone whose access to this workspace was revoked + * (ENG-2186). Scoped to the workspace — not merely to its organization — so it matches both the + * authoring picker's options and the runner's send-time backstop. Emails are compared + * case-insensitively. */ const verifyRecipientsAllowed: WorkflowApiContext["verifyRecipientsAllowed"] = async ({ workspaceId, emails, }) => { - const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - const memberEmails = await getOrganizationMemberEmails(organizationId); + const memberEmails = await getWorkspaceMemberEmails(workspaceId); const disallowedEmails = emails.filter((email) => !memberEmails.has(normalizeEmailForComparison(email))); return { disallowedEmails }; }; diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 2316a978cc59..d1bc0d0703db 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2484,7 +2484,7 @@ checksums: workspace/settings/billing/plan_scale_feature_responses: f2be033ebf6c86a664b812b4a918647f workspace/settings/billing/plan_scale_feature_security: 6671961cf8d8413d1740b13901bcc033 workspace/settings/billing/plan_scale_feature_semantic_analysis: 1441e34cacd26f0aa27af4ebad6e5c54 - workspace/settings/billing/plan_scale_feature_workflows: b0c9c8615a9ba7d9cb73e767290a7f72 + workspace/settings/billing/plan_scale_feature_workflow_runs: 22007bc0ad530bfd56b8a3788bdef02f workspace/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef workspace/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599 workspace/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d @@ -2525,6 +2525,7 @@ checksums: workspace/settings/billing/trial_warning_add_payment_method: 5e6879babc7acb05a258de64fef57262 workspace/settings/billing/trial_warning_remind_me_later: a12f38fb4352c31ca0d43c05303b7257 workspace/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1 + workspace/settings/billing/unlimited_workflow_runs: 61221f0506d1bf247f732a31959dc146 workspace/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173 workspace/settings/billing/unlock_all_plan_features: f494466ed4d974763434fb15c0d63750 workspace/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2 @@ -4031,6 +4032,8 @@ checksums: workspace/workflows/archive_workflow_confirmation: 83f351c186e67a3c5308af82bf971d65 workspace/workflows/archive_workflow_description: 06e437998a762e504d7d0101b0c53565 workspace/workflows/auto_layout: 553d4f054655684130a8b5bc8f800bf0 + workspace/workflows/autosave_blocked: 675161fc3d48278839a611e2795b9725 + workspace/workflows/autosave_blocked_tooltip: b5346c95ec8070002565866317f75f06 workspace/workflows/autosave_failed: 5d9b22b31b83828d5eeaeae87d2b6d14 workspace/workflows/autosave_failed_tooltip: f4cb6255fd863b741275a7ff6d2701c6 workspace/workflows/autosave_failed_tooltip_rejected: 5f90aa698c936629a7b959520dc9358e @@ -4061,6 +4064,8 @@ checksums: workspace/workflows/email_to_label: fc84f35b3c44796dfcdd4b096c9b8d3d workspace/workflows/email_to_placeholder: 1323bfdf1926a863c93bc0b37ae61218 workspace/workflows/email_to_required: d29dd2c5bdebdde1047f4baae12269a4 + workspace/workflows/email_to_unavailable_group_label: ce6c023dd8cad961910a0bd96cc40f5c + workspace/workflows/email_to_unavailable_warning: e52afafce65a359b1a5e71257730aa8b workspace/workflows/enable_blocked_unsaved_changes: 07fc359db9158b09d612f4f0e0e2df5e workspace/workflows/enable_failed: 997437c65cc018c4d3cf7622e043d48d workspace/workflows/enable_success: 384d5e80e013c74f8e092f41ed5bae5c @@ -4069,6 +4074,7 @@ checksums: workspace/workflows/if_else_summary: f166c79dcfd8a9595ac364c96caaf7e1 workspace/workflows/inspector_unsupported_node: 478f0de84863ccf95702428b1cc95be0 workspace/workflows/load_failed: 4e79a59e05cfc390294673e4aa111f02 + workspace/workflows/name_invalid: c1f750725318d5e385443519730729da workspace/workflows/name_required: a2866fa94293bc08be10ef65e0939423 workspace/workflows/no_results_description: b358733531cc8290f8b311a8cc3bf05d workspace/workflows/no_results_title: e3f2c57c4024d33a20f192d45284d709 diff --git a/apps/web/lib/organization/service.test.ts b/apps/web/lib/organization/service.test.ts index 2f74dadf9dd4..fae3f90bcff2 100644 --- a/apps/web/lib/organization/service.test.ts +++ b/apps/web/lib/organization/service.test.ts @@ -4,6 +4,7 @@ import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { updateUser } from "@/lib/user/service"; +import { getWorkspaces } from "@/lib/workspace/service"; import { cleanupStripeCustomer, ensureCloudStripeSetupForOrganization, @@ -11,8 +12,8 @@ import { import { createOrganization, deleteOrganization, + getMonthlyOrganizationWorkflowRunCount, getOrganization, - getOrganizationMemberEmails, getOrganizationsByUserId, select as organizationSelect, subscribeOrganizationMembersToSurveyResponses, @@ -35,8 +36,8 @@ vi.mock("@formbricks/database", () => ({ user: { findUnique: vi.fn(), }, - membership: { - findMany: vi.fn(), + workflowRun: { + aggregate: vi.fn(), }, }, })); @@ -45,6 +46,10 @@ vi.mock("@/lib/user/service", () => ({ updateUser: vi.fn(), })); +vi.mock("@/lib/workspace/service", () => ({ + getWorkspaces: vi.fn(), +})); + vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({ ensureCloudStripeSetupForOrganization: vi.fn().mockResolvedValue(undefined), cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined), @@ -415,50 +420,58 @@ describe("Organization Service", () => { }); }); - describe("getOrganizationMemberEmails (send_email recipient allowlist, ENG-2029)", () => { - test("queries only active members of the organization", async () => { - vi.mocked(prisma.membership.findMany).mockResolvedValue([]); - - await getOrganizationMemberEmails("org_1"); - - expect(prisma.membership.findMany).toHaveBeenCalledWith({ - where: { organizationId: "org_1", user: { isActive: true } }, - select: { user: { select: { email: true } } }, - }); - }); - - test("returns a lowercased, whitespace-trimmed set for case-insensitive matching", async () => { - vi.mocked(prisma.membership.findMany).mockResolvedValue([ - { user: { email: " Member@Corp.Example " } }, - { user: { email: "second@corp.example" } }, - ] as never); - - const result = await getOrganizationMemberEmails("org_1"); - - expect(result).toEqual(new Set(["member@corp.example", "second@corp.example"])); + describe("getMonthlyOrganizationWorkflowRunCount", () => { + const mockOrganization = { + id: "org_1", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: "cus_1", + limits: { workspaces: 5, monthly: { responses: 5000, workflowRuns: 1000 } }, + usageCycleAnchor: null, + stripe: null, + }, + isAISmartToolsEnabled: false, + whitelabel: null, + }; + + test("counts non-dry workflow runs across the organization's workspaces in the billing cycle", async () => { + vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never); + vi.mocked(getWorkspaces).mockResolvedValue([{ id: "ws_1" }, { id: "ws_2" }] as never); + vi.mocked(prisma.workflowRun.aggregate).mockResolvedValue({ _count: { id: 42 } } as never); + + const result = await getMonthlyOrganizationWorkflowRunCount("cms634kob000001uzrelh0qeb"); + + expect(result).toBe(42); + const aggregateArgs = vi.mocked(prisma.workflowRun.aggregate).mock.calls[0][0]; + expect(aggregateArgs.where?.AND).toEqual( + expect.arrayContaining([ + { workspaceId: { in: ["ws_1", "ws_2"] } }, + { isDryRun: false }, + expect.objectContaining({ createdAt: expect.any(Object) }), + ]) + ); }); - test("drops memberships with a missing user or empty email", async () => { - vi.mocked(prisma.membership.findMany).mockResolvedValue([ - { user: { email: "kept@corp.example" } }, - { user: null }, - { user: { email: null } }, - { user: { email: "" } }, - ] as never); - - const result = await getOrganizationMemberEmails("org_1"); + test("throws ResourceNotFoundError when the organization does not exist", async () => { + vi.mocked(prisma.organization.findUnique).mockResolvedValue(null); - expect(result).toEqual(new Set(["kept@corp.example"])); + await expect(getMonthlyOrganizationWorkflowRunCount("cmmissingorg00000000000a")).rejects.toThrow( + ResourceNotFoundError + ); }); test("wraps a known Prisma error in DatabaseError", async () => { - const prismaError = new Prisma.PrismaClientKnownRequestError("db down", { - code: "P2002", - clientVersion: "1.0.0", - }); - vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError); + vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never); + vi.mocked(getWorkspaces).mockResolvedValue([{ id: "ws_1" }] as never); + vi.mocked(prisma.workflowRun.aggregate).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("db down", { code: "P2002", clientVersion: "1.0.0" }) + ); - await expect(getOrganizationMemberEmails("org_1")).rejects.toThrow(DatabaseError); + await expect(getMonthlyOrganizationWorkflowRunCount("cms634kob000001uzrelh0qeb")).rejects.toThrow( + DatabaseError + ); }); }); }); diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts index f50a20f2d3fb..c1d4054cc441 100644 --- a/apps/web/lib/organization/service.ts +++ b/apps/web/lib/organization/service.ts @@ -17,7 +17,6 @@ import { TUserNotificationSettings } from "@formbricks/types/user"; import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants"; import { updateUser } from "@/lib/user/service"; import { getBillingUsageCycleWindow } from "@/lib/utils/billing"; -import { normalizeEmailForComparison } from "@/lib/utils/email"; import { getWorkspaces } from "@/lib/workspace/service"; import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing"; import { deleteHubTenantData } from "@/modules/hub/service"; @@ -142,42 +141,6 @@ export const getOrganizationByWorkspaceId = reactCache( } ); -/** - * Lowercased set of the email addresses of every active member of an organization. Used as the - * recipient allowlist for workflow `send_email` actions (ENG-2029): a literal recipient address is - * only permitted when it belongs to an active organization member, so a workflow cannot silently - * forward response data to an arbitrary external inbox. Emails are lowercased for case-insensitive - * matching. - */ -export const getOrganizationMemberEmails = reactCache( - async (organizationId: string): Promise> => { - validateInputs([organizationId, ZString]); - - try { - // Only active users: a deactivated (soft-deleted) member has had access revoked and must not - // remain on the send_email recipient allowlist (ENG-2029). - const memberships = await prisma.membership.findMany({ - where: { organizationId, user: { isActive: true } }, - select: { user: { select: { email: true } } }, - }); - - return new Set( - memberships - .map((membership) => - membership.user?.email ? normalizeEmailForComparison(membership.user.email) : undefined - ) - .filter((email): email is string => Boolean(email)) - ); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - } -); - export const getOrganization = reactCache(async (organizationId: string): Promise => { validateInputs([organizationId, ZString]); @@ -401,6 +364,47 @@ export const getMonthlyOrganizationResponseCount = reactCache( } ); +export const getMonthlyOrganizationWorkflowRunCount = reactCache( + async (organizationId: string): Promise => { + validateInputs([organizationId, ZId]); + + try { + const organization = await getOrganization(organizationId); + if (!organization) { + throw new ResourceNotFoundError("Organization", organizationId); + } + + const usageCycleWindow = getBillingUsageCycleWindow(organization.billing); + + const workspaces = await getWorkspaces(organizationId); + const workspaceIds = workspaces.map((workspace) => workspace.id); + + // Mirror the metered usage: count only non-dry runs in the current billing cycle, scoped to the + // organization's workspaces. Dry runs are excluded from billing, so they must not show as usage. + const workflowRunAggregations = await prisma.workflowRun.aggregate({ + _count: { + id: true, + }, + where: { + AND: [ + { workspaceId: { in: workspaceIds } }, + { isDryRun: false }, + { createdAt: { gte: usageCycleWindow.start, lt: usageCycleWindow.end } }, + ], + }, + }); + + return workflowRunAggregations._count.id; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + export const subscribeOrganizationMembersToSurveyResponses = async ( surveyId: string, createdBy: string, diff --git a/apps/web/lib/workspace/service.test.ts b/apps/web/lib/workspace/service.test.ts index 28524ae302db..7d26d5528175 100644 --- a/apps/web/lib/workspace/service.test.ts +++ b/apps/web/lib/workspace/service.test.ts @@ -9,6 +9,8 @@ import { getUserWorkspacesByOrganizationIds, getWorkspace, getWorkspaceLegacyStoragePrefixes, + getWorkspaceMemberEmails, + getWorkspaceMembers, getWorkspaces, } from "./service"; @@ -514,4 +516,90 @@ describe("Workspace Service", () => { await expect(getWorkspaceLegacyStoragePrefixes(createId())).rejects.toThrow(DatabaseError); }); }); + + // Fresh cuid per test: both functions are `reactCache`d, so reusing a workspace id would replay a + // previous test's result instead of the mock set up here. + describe("getWorkspaceMembers / getWorkspaceMemberEmails (send_email recipient allowlist)", () => { + const member = (name: string, email: string) => ({ user: { name, email } }); + + test("selects the members who can access the workspace: org owner/manager, or a team linked to it", async () => { + // The filter is the behavior here — it decides who may receive a workspace's response data, so + // it is asserted directly. It mirrors `checkAuthorizationUpdated`'s workspace access: an + // owner/manager reaches every workspace in the org, everyone else only through a linked team + // (any `WorkspaceTeam` permission, since `read` already grants access). The organization comes + // from the workspace itself, never from a caller-supplied id. + const workspaceId = createId(); + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + + await getWorkspaceMembers(workspaceId); + + expect(prisma.membership.findMany).toHaveBeenCalledWith({ + where: { + organization: { workspaces: { some: { id: workspaceId } } }, + user: { isActive: true }, + OR: [ + { role: { in: ["owner", "manager"] } }, + { user: { teamUsers: { some: { team: { workspaceTeams: { some: { workspaceId } } } } } } }, + ], + }, + select: { user: { select: { name: true, email: true } } }, + }); + }); + + test("returns the name and email of each member with workspace access", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + member("Owner", "owner@corp.example"), + member("Team Member", "member@corp.example"), + ] as never); + + await expect(getWorkspaceMembers(createId())).resolves.toEqual([ + { name: "Owner", email: "owner@corp.example" }, + { name: "Team Member", email: "member@corp.example" }, + ]); + }); + + test("drops a member with an empty email so a blank recipient can never match", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + member("Kept", "kept@corp.example"), + member("No Email", ""), + ] as never); + + await expect(getWorkspaceMembers(createId())).resolves.toEqual([ + { name: "Kept", email: "kept@corp.example" }, + ]); + }); + + test("normalizes the allowlist emails so recipient matching stays case-insensitive", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + member("Mixed Case", " Member@Corp.Example "), + member("Second", "second@corp.example"), + ] as never); + + await expect(getWorkspaceMemberEmails(createId())).resolves.toEqual( + new Set(["member@corp.example", "second@corp.example"]) + ); + }); + + test("returns an empty allowlist when nobody can access the workspace (fails closed)", async () => { + // A revoked team, a deleted workspace or a foreign id all land here; the callers treat an empty + // set as "reject every literal recipient" rather than "skip the check". + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + + await expect(getWorkspaceMemberEmails(createId())).resolves.toEqual(new Set()); + }); + + test("throws ValidationError for an invalid workspace id", async () => { + await expect(getWorkspaceMemberEmails("not-a-cuid")).rejects.toThrow(ValidationError); + }); + + test("throws DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError); + + await expect(getWorkspaceMemberEmails(createId())).rejects.toThrow(DatabaseError); + }); + }); }); diff --git a/apps/web/lib/workspace/service.ts b/apps/web/lib/workspace/service.ts index 28327ae96672..7bb5fdd5bd81 100644 --- a/apps/web/lib/workspace/service.ts +++ b/apps/web/lib/workspace/service.ts @@ -6,6 +6,7 @@ import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import type { TWorkspace } from "@formbricks/types/workspace"; import { ITEMS_PER_PAGE } from "../constants"; +import { normalizeEmailForComparison } from "../utils/email"; import { validateInputs } from "../utils/validate"; const selectWorkspace = { @@ -158,6 +159,65 @@ export const getWorkspaceLegacyStoragePrefixes = reactCache( } ); +/** A member who can access a workspace, as the `send_email` recipient picker needs them. */ +export interface TWorkspaceMember { + name: string; + email: string; +} + +/** + * Everyone who can access a workspace, name included. The single source of truth the `send_email` + * recipient picker offers options from, so the authoring UI cannot offer an address that the + * enable-time gate and the runner backstop would then reject (ENG-2186). + * + * "Can access" mirrors the authorization the real request path enforces (`checkAuthorizationUpdated` + * via `requireSessionWorkspaceAccess`): an organization owner/manager reaches every workspace in the + * organization, and every other role reaches a workspace only through a team linked to it — at any + * `WorkspaceTeam` permission, since `read` already grants access. + * + * The organization is resolved from the workspace itself rather than taken from the caller, so the + * tenant boundary cannot be widened by passing a foreign organization id. Deactivated (soft-deleted) + * users are excluded: their access has been revoked. + */ +export const getWorkspaceMembers = reactCache(async (workspaceId: string): Promise => { + validateInputs([workspaceId, ZId]); + + try { + const memberships = await prisma.membership.findMany({ + where: { + organization: { workspaces: { some: { id: workspaceId } } }, + user: { isActive: true }, + OR: [ + { role: { in: ["owner", "manager"] } }, + { user: { teamUsers: { some: { team: { workspaceTeams: { some: { workspaceId } } } } } } }, + ], + }, + select: { user: { select: { name: true, email: true } } }, + }); + + return memberships.map((membership) => membership.user).filter((user) => user.email.length > 0); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +/** + * Lowercased set of the email addresses of everyone who can access a workspace. Used as the + * recipient allowlist for workflow `send_email` actions: a literal recipient address is only + * permitted when its owner can access the workspace whose response data the email carries, so a + * workflow can neither forward that data to an arbitrary external inbox (ENG-2029) nor keep + * emailing a member whose access to this workspace was revoked (ENG-2186). Emails are normalized + * for case-insensitive matching. + */ +export const getWorkspaceMemberEmails = reactCache(async (workspaceId: string): Promise> => { + const members = await getWorkspaceMembers(workspaceId); + return new Set(members.map((member) => normalizeEmailForComparison(member.email))); +}); + export const getOrganizationWorkspacesCount = reactCache(async (organizationId: string): Promise => { validateInputs([organizationId, ZId]); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index ad1a0c04123c..b3feeed59aa2 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5.000 Antworten / Monat mit dynamischer Preisgestaltung", "plan_scale_feature_security": "2FA & Spam-Schutz", "plan_scale_feature_semantic_analysis": "Semantische Analyse (KI)", - "plan_scale_feature_workflows": "Workflows", + "plan_scale_feature_workflow_runs": "1.000 Workflow-Ausführungen / Monat mit dynamischer Preisgestaltung", "plan_scale_feature_workspaces": "5 Workspaces", "plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle deinen Plan direkt in Formbricks.", "plan_selection_title": "Wähle deinen Plan", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Alle Funktionen freischalten", "trial_warning_remind_me_later": "Später erinnern", "unlimited_responses": "Unbegrenzte Antworten", + "unlimited_workflow_runs": "Unbegrenzte Workflow-Ausführungen", "unlimited_workspaces": "Unbegrenzte Workspaces", "unlock_all_plan_features": "Alle {plan}-Features freischalten", "upgrade": "Upgrade", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Bist du sicher, dass du \"{name}\" archivieren möchtest? Du kannst ihn später wiederherstellen.", "archive_workflow_description": "Beim Archivieren wird der Workflow aus der Liste ausgeblendet. Du kannst ihn später wiederherstellen.", "auto_layout": "Auto-Layout", + "autosave_blocked": "Nicht gespeichert", + "autosave_blocked_tooltip": "Deine Änderungen werden nicht gespeichert. Gib dem Workflow einen Namen, um fortzufahren.", "autosave_failed": "Speichern fehlgeschlagen", "autosave_failed_tooltip": "Deine neuesten Änderungen konnten nicht gespeichert werden. Überprüfe deine Verbindung und versuche es erneut.", "autosave_failed_tooltip_rejected": "Deine letzten Änderungen konnten nicht gespeichert werden: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Senden an", "email_to_placeholder": "team@beispiel.de", "email_to_required": "Wähle aus, wer diese E-Mail erhalten soll.", + "email_to_unavailable_group_label": "Nicht verfügbar", + "email_to_unavailable_warning": "Dieser Empfänger ist nicht mehr verfügbar – möglicherweise hat er den Zugriff auf diesen Workspace verloren oder das Umfragefeld, aus dem er stammte, wurde entfernt. Wähle einen neuen aus; diese E-Mail kann erst versendet werden, wenn du das getan hast.", "enable_blocked_unsaved_changes": "Deine letzten Änderungen konnten nicht gespeichert werden, daher wurde der Workflow nicht aktiviert.", "enable_failed": "Der Workflow konnte nicht aktiviert werden.", "enable_success": "Workflow aktiviert.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Verzweige den Workflow basierend auf einer Bedingung.", "inspector_unsupported_node": "Dieser Knotentyp hat noch kein Konfigurationsformular.", "load_failed": "Workflow konnte nicht geladen werden.", + "name_invalid": "Der Name muss zwischen 1 und 120 Zeichen lang sein.", "name_required": "Bitte einen Namen eingeben.", "no_results_description": "Versuche, deine Suche oder Filter anzupassen.", "no_results_title": "Keine Workflows gefunden", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 32e0de60adcb..599600ea3690 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5,000 responses / month with dynamic pricing", "plan_scale_feature_security": "2FA & spam protection", "plan_scale_feature_semantic_analysis": "Semantic Analysis (AI)", - "plan_scale_feature_workflows": "Workflows", + "plan_scale_feature_workflow_runs": "1,000 workflow runs / month with dynamic pricing", "plan_scale_feature_workspaces": "5 workspaces", "plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.", "plan_selection_title": "Choose your plan", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Unlock all features", "trial_warning_remind_me_later": "Remind me later", "unlimited_responses": "Unlimited Responses", + "unlimited_workflow_runs": "Unlimited Workflow Runs", "unlimited_workspaces": "Unlimited Workspaces", "unlock_all_plan_features": "Unlock all {plan} features", "upgrade": "Upgrade", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Are you sure you want to archive \"{name}\"? You can restore it later.", "archive_workflow_description": "Archiving hides the workflow from the list. You can restore it later.", "auto_layout": "Auto layout", + "autosave_blocked": "Not saved", + "autosave_blocked_tooltip": "Your changes aren't being saved. Give the workflow a name to continue.", "autosave_failed": "Save failed", "autosave_failed_tooltip": "Your latest changes couldn't be saved. Check your connection and try again.", "autosave_failed_tooltip_rejected": "Your latest changes couldn't be saved: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Send to", "email_to_placeholder": "team@example.com", "email_to_required": "Pick who should receive this email.", + "email_to_unavailable_group_label": "Not available", + "email_to_unavailable_warning": "This recipient is no longer available — they may have lost access to this workspace, or the survey field they came from was removed. Pick a new one; this email can't be sent until you do.", "enable_blocked_unsaved_changes": "Your latest changes couldn't be saved, so the workflow wasn't enabled.", "enable_failed": "Could not enable the workflow.", "enable_success": "Workflow enabled.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Branch the workflow based on a condition.", "inspector_unsupported_node": "This node type doesn't have a configuration form yet.", "load_failed": "Could not load the workflow.", + "name_invalid": "Name must be between 1 and 120 characters.", "name_required": "Please enter a name.", "no_results_description": "Try adjusting your search or filters.", "no_results_title": "No workflows found", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 1ba7c8bb5fc7..46fdeb971e5e 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5.000 respuestas al mes con precios dinámicos", "plan_scale_feature_security": "2FA y protección antispam", "plan_scale_feature_semantic_analysis": "Análisis semántico (IA)", - "plan_scale_feature_workflows": "Workflows", + "plan_scale_feature_workflow_runs": "1.000 ejecuciones de flujo de trabajo al mes con precios dinámicos", "plan_scale_feature_workspaces": "5 espacios de trabajo", "plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.", "plan_selection_title": "Elige tu plan", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Desbloquear todas las funciones", "trial_warning_remind_me_later": "Recuérdamelo más tarde", "unlimited_responses": "Respuestas ilimitadas", + "unlimited_workflow_runs": "Ejecuciones de flujo de trabajo ilimitadas", "unlimited_workspaces": "Espacios de trabajo ilimitados", "unlock_all_plan_features": "Desbloquea todas las funciones de {plan}", "upgrade": "Actualizar", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "¿Estás seguro de que quieres archivar \"{name}\"? Puedes restaurarlo más tarde.", "archive_workflow_description": "Archivar oculta el flujo de trabajo de la lista. Puedes restaurarlo más tarde.", "auto_layout": "Diseño automático", + "autosave_blocked": "No guardado", + "autosave_blocked_tooltip": "Tus cambios no se están guardando. Dale un nombre al flujo de trabajo para continuar.", "autosave_failed": "Error al guardar", "autosave_failed_tooltip": "No se pudieron guardar tus últimos cambios. Comprueba tu conexión e inténtalo de nuevo.", "autosave_failed_tooltip_rejected": "No se pudieron guardar tus últimos cambios: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Enviar a", "email_to_placeholder": "equipo@ejemplo.com", "email_to_required": "Elige quién debe recibir este correo.", + "email_to_unavailable_group_label": "No disponible", + "email_to_unavailable_warning": "Este destinatario ya no está disponible: es posible que haya perdido el acceso a este espacio de trabajo o que se haya eliminado el campo de encuesta del que procedía. Elige uno nuevo; este correo no se puede enviar hasta que lo hagas.", "enable_blocked_unsaved_changes": "No se pudieron guardar tus últimos cambios, así que el flujo de trabajo no se habilitó.", "enable_failed": "No se pudo activar el flujo de trabajo.", "enable_success": "Flujo de trabajo activado.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Ramifica el flujo de trabajo según una condición.", "inspector_unsupported_node": "Este tipo de nodo aún no tiene un formulario de configuración.", "load_failed": "No se pudo cargar el flujo de trabajo.", + "name_invalid": "El nombre debe tener entre 1 y 120 caracteres.", "name_required": "Introduce un nombre.", "no_results_description": "Prueba a ajustar tu búsqueda o filtros.", "no_results_title": "No se encontraron flujos de trabajo", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 31aa9c09f1e8..2fe5ecf5afba 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5 000 réponses / mois avec tarification dynamique", "plan_scale_feature_security": "2FA et protection anti-spam", "plan_scale_feature_semantic_analysis": "Analyse sémantique (IA)", - "plan_scale_feature_workflows": "Workflows", + "plan_scale_feature_workflow_runs": "1 000 exécutions de workflow / mois avec tarification dynamique", "plan_scale_feature_workspaces": "5 espaces de travail", "plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.", "plan_selection_title": "Choisis ta formule", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Débloquer toutes les fonctionnalités", "trial_warning_remind_me_later": "Me le rappeler plus tard", "unlimited_responses": "Réponses illimitées", + "unlimited_workflow_runs": "Exécutions de flux de travail illimitées", "unlimited_workspaces": "Espaces de travail illimités", "unlock_all_plan_features": "Débloquer toutes les fonctionnalités {plan}", "upgrade": "Mise à niveau", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Es-tu sûr de vouloir archiver « {name} » ? Tu pourras le restaurer plus tard.", "archive_workflow_description": "L'archivage masque le workflow de la liste. Tu pourras le restaurer plus tard.", "auto_layout": "Disposition automatique", + "autosave_blocked": "Non enregistré", + "autosave_blocked_tooltip": "Tes modifications ne sont pas enregistrées. Donne un nom au workflow pour continuer.", "autosave_failed": "Échec de l'enregistrement", "autosave_failed_tooltip": "Tes dernières modifications n'ont pas pu être enregistrées. Vérifie ta connexion et réessaie.", "autosave_failed_tooltip_rejected": "Tes dernières modifications n'ont pas pu être enregistrées : {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Envoyer à", "email_to_placeholder": "equipe@exemple.com", "email_to_required": "Choisis qui doit recevoir cet e-mail.", + "email_to_unavailable_group_label": "Non disponible", + "email_to_unavailable_warning": "Ce destinataire n'est plus disponible — il a peut-être perdu l'accès à cet espace de travail, ou le champ d'enquête d'où il provenait a été supprimé. Choisis-en un nouveau ; cet email ne peut pas être envoyé tant que tu ne l'auras pas fait.", "enable_blocked_unsaved_changes": "Tes dernières modifications n'ont pas pu être enregistrées, donc le workflow n'a pas été activé.", "enable_failed": "Impossible d'activer le workflow.", "enable_success": "Workflow activé.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Brancher le workflow selon une condition.", "inspector_unsupported_node": "Ce type de nœud n'a pas encore de formulaire de configuration.", "load_failed": "Impossible de charger le workflow.", + "name_invalid": "Le nom doit contenir entre 1 et 120 caractères.", "name_required": "Veuillez saisir un nom.", "no_results_description": "Essaie d'ajuster ta recherche ou tes filtres.", "no_results_title": "Aucun workflow trouvé", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 994eb22d65be..90a00b4c116f 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5000 válasz / hónap dinamikus árképzéssel", "plan_scale_feature_security": "2FA és spam védelem", "plan_scale_feature_semantic_analysis": "Szemantikai elemzés (AI)", - "plan_scale_feature_workflows": "Munkafolyamatok", + "plan_scale_feature_workflow_runs": "1000 munkafolyamat-futtatás / hónap dinamikus árképzéssel", "plan_scale_feature_workspaces": "5 munkaterület", "plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.", "plan_selection_title": "Csomag kiválasztása", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Minden funkció feloldása", "trial_warning_remind_me_later": "Emlékeztessen később", "unlimited_responses": "Korlátlan válaszok", + "unlimited_workflow_runs": "Korlátlan munkafolyamat-futtatások", "unlimited_workspaces": "Korlátlan munkaterület", "unlock_all_plan_features": "Az összes {plan} funkció feloldása", "upgrade": "Frissítés", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Biztos benne, hogy archiválni kívánja a következőt: \"{name}\"? Később visszaállíthatja.", "archive_workflow_description": "Az archiválás elrejti a munkafolyamatot a listából. Később visszaállíthatja.", "auto_layout": "Automatikus elrendezés", + "autosave_blocked": "Nem mentve", + "autosave_blocked_tooltip": "Az Ön változtatásai nincsenek elmentve. Kérem, adjon nevet a munkafolyamatnak a folytatáshoz.", "autosave_failed": "A mentés sikertelen volt", "autosave_failed_tooltip": "A legutóbbi módosításait nem sikerült elmenteni. Kérem, ellenőrizze a kapcsolatot, és próbálja újra.", "autosave_failed_tooltip_rejected": "A legutóbbi módosításokat nem sikerült menteni: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Címzett", "email_to_placeholder": "csapat@pelda.hu", "email_to_required": "Válassza ki, hogy ki kapja meg ezt az e-mailt.", + "email_to_unavailable_group_label": "Nem elérhető", + "email_to_unavailable_warning": "Ez a címzett már nem elérhető — lehet, hogy elvesztette a hozzáférést ehhez a munkaterülethez, vagy a felmérési mező, amelyből származott, eltávolításra került. Kérjük, válasszon egy újat; ez az e-mail nem küldhető el, amíg ezt meg nem teszi.", "enable_blocked_unsaved_changes": "A legutóbbi módosításokat nem sikerült menteni, ezért a munkafolyamat nem lett engedélyezve.", "enable_failed": "A munkafolyamat nem engedélyezhető.", "enable_success": "Munkafolyamat engedélyezve.", @@ -4231,6 +4236,7 @@ "if_else_summary": "A munkafolyamat elágaztatása egy feltétel alapján.", "inspector_unsupported_node": "Ez a csomóponttípus még nem rendelkezik konfigurációs űrlappal.", "load_failed": "A munkafolyamat betöltése sikertelen volt.", + "name_invalid": "A névnek 1 és 120 karakter között kell lennie.", "name_required": "Adj meg egy nevet.", "no_results_description": "Kérem, módosítsa a keresési feltételeket vagy a szűrőket.", "no_results_title": "Nem találhatók munkafolyamatok", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 2d4eba2270cc..097810cb4a95 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "月間5,000件のレスポンス(動的価格設定)", "plan_scale_feature_security": "2FA&スパム保護", "plan_scale_feature_semantic_analysis": "セマンティック分析(AI)", - "plan_scale_feature_workflows": "ワークフロー", + "plan_scale_feature_workflow_runs": "月間1,000回のワークフロー実行、従量課金制", "plan_scale_feature_workspaces": "5つのワークスペース", "plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。", "plan_selection_title": "プランを選択", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "すべての機能をアンロック", "trial_warning_remind_me_later": "後で通知", "unlimited_responses": "無制限の回答", + "unlimited_workflow_runs": "無制限のワークフロー実行", "unlimited_workspaces": "無制限ワークスペース", "unlock_all_plan_features": "すべての{plan}機能をアンロック", "upgrade": "アップグレード", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "「{name}」をアーカイブしてもよろしいですか?後で復元できます。", "archive_workflow_description": "アーカイブすると、ワークフローがリストに表示されなくなります。後で復元できます。", "auto_layout": "自動レイアウト", + "autosave_blocked": "保存されていません", + "autosave_blocked_tooltip": "変更が保存されていません。続行するには、ワークフローに名前を付けてください。", "autosave_failed": "保存に失敗しました", "autosave_failed_tooltip": "最新の変更を保存できませんでした。接続を確認して、もう一度お試しください。", "autosave_failed_tooltip_rejected": "最新の変更を保存できませんでした: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "送信先", "email_to_placeholder": "team@example.com", "email_to_required": "このメールを受信する宛先を選択してください。", + "email_to_unavailable_group_label": "利用不可", + "email_to_unavailable_warning": "この宛先は利用できなくなりました。ワークスペースへのアクセス権を失ったか、元となるアンケートフィールドが削除された可能性があります。新しい宛先を選択してください。選択するまでこのメールは送信できません。", "enable_blocked_unsaved_changes": "最新の変更を保存できなかったため、ワークフローは有効化されませんでした。", "enable_failed": "ワークフローを有効化できませんでした。", "enable_success": "ワークフローを有効化しました。", @@ -4231,6 +4236,7 @@ "if_else_summary": "条件に基づいてワークフローを分岐します。", "inspector_unsupported_node": "このノードタイプにはまだ設定フォームがありません。", "load_failed": "ワークフローを読み込めませんでした。", + "name_invalid": "名前は1文字以上120文字以内で入力してください。", "name_required": "名前を入力してください。", "no_results_description": "検索条件やフィルターを調整してみてください。", "no_results_title": "ワークフローが見つかりません", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 3c7330b1f6ec..c2c3ef1fdeb5 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5.000 reacties / maand met dynamische prijzen", "plan_scale_feature_security": "2FA & spambescherming", "plan_scale_feature_semantic_analysis": "Semantische analyse (AI)", - "plan_scale_feature_workflows": "Workflows", + "plan_scale_feature_workflow_runs": "1.000 workflowuitvoeringen / maand met dynamische prijzen", "plan_scale_feature_workspaces": "5 werkruimtes", "plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.", "plan_selection_title": "Kies je abonnement", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Ontgrendel alle functies", "trial_warning_remind_me_later": "Herinner me later", "unlimited_responses": "Onbeperkte reacties", + "unlimited_workflow_runs": "Onbeperkte Workflow-uitvoeringen", "unlimited_workspaces": "Onbeperkt werkruimtes", "unlock_all_plan_features": "Ontgrendel alle {plan}-functies", "upgrade": "Upgraden", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Weet je zeker dat je \"{name}\" wilt archiveren? Je kunt deze later herstellen.", "archive_workflow_description": "Archiveren verbergt de workflow uit de lijst. Je kunt deze later herstellen.", "auto_layout": "Automatische indeling", + "autosave_blocked": "Niet opgeslagen", + "autosave_blocked_tooltip": "Je wijzigingen worden niet opgeslagen. Geef de workflow een naam om door te gaan.", "autosave_failed": "Opslaan mislukt", "autosave_failed_tooltip": "Je laatste wijzigingen konden niet worden opgeslagen. Controleer je verbinding en probeer het opnieuw.", "autosave_failed_tooltip_rejected": "Je laatste wijzigingen konden niet worden opgeslagen: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Verzenden naar", "email_to_placeholder": "team@voorbeeld.nl", "email_to_required": "Kies wie deze e-mail moet ontvangen.", + "email_to_unavailable_group_label": "Niet beschikbaar", + "email_to_unavailable_warning": "Deze ontvanger is niet meer beschikbaar — ze hebben mogelijk geen toegang meer tot deze workspace, of het enquêteveld waaruit ze kwamen is verwijderd. Kies een nieuwe ontvanger; deze e-mail kan niet worden verzonden totdat je dat doet.", "enable_blocked_unsaved_changes": "Je laatste wijzigingen konden niet worden opgeslagen, dus de workflow is niet ingeschakeld.", "enable_failed": "Workflow kon niet worden ingeschakeld.", "enable_success": "Workflow ingeschakeld.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Splits de workflow op basis van een voorwaarde.", "inspector_unsupported_node": "Dit type node heeft nog geen configuratieformulier.", "load_failed": "Kon de workflow niet laden.", + "name_invalid": "Naam moet tussen de 1 en 120 tekens zijn.", "name_required": "Voer een naam in.", "no_results_description": "Probeer je zoekopdracht of filters aan te passen.", "no_results_title": "Geen workflows gevonden", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 9866647b2b25..85b9214b8464 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos", "plan_scale_feature_security": "Autenticação 2FA e proteção contra spam", "plan_scale_feature_semantic_analysis": "Análise Semântica (IA)", - "plan_scale_feature_workflows": "Fluxos de trabalho", + "plan_scale_feature_workflow_runs": "1.000 execuções de workflow / mês com preços dinâmicos", "plan_scale_feature_workspaces": "5 espaços de trabalho", "plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.", "plan_selection_title": "Escolha seu plano", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Desbloquear todos os recursos", "trial_warning_remind_me_later": "Lembrar mais tarde", "unlimited_responses": "Respostas Ilimitadas", + "unlimited_workflow_runs": "Execuções de Fluxo de Trabalho Ilimitadas", "unlimited_workspaces": "Workspaces Ilimitados", "unlock_all_plan_features": "Desbloquear todos os recursos do {plan}", "upgrade": "Atualizar", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Tem certeza que deseja arquivar \"{name}\"? Você pode restaurá-lo depois.", "archive_workflow_description": "Arquivar oculta o fluxo de trabalho da lista. Você pode restaurá-lo depois.", "auto_layout": "Layout automático", + "autosave_blocked": "Não salvo", + "autosave_blocked_tooltip": "Suas alterações não estão sendo salvas. Dê um nome ao fluxo de trabalho para continuar.", "autosave_failed": "Falha ao salvar", "autosave_failed_tooltip": "Suas últimas alterações não puderam ser salvas. Verifique sua conexão e tente novamente.", "autosave_failed_tooltip_rejected": "Suas últimas alterações não puderam ser salvas: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Enviar para", "email_to_placeholder": "equipe@exemplo.com", "email_to_required": "Escolha quem deve receber este e-mail.", + "email_to_unavailable_group_label": "Não disponível", + "email_to_unavailable_warning": "Este destinatário não está mais disponível — ele pode ter perdido acesso a este workspace ou o campo de pesquisa de onde ele veio foi removido. Escolha um novo; este e-mail não pode ser enviado até que você faça isso.", "enable_blocked_unsaved_changes": "Suas últimas alterações não puderam ser salvas, então o fluxo de trabalho não foi ativado.", "enable_failed": "Não foi possível ativar o fluxo de trabalho.", "enable_success": "Fluxo de trabalho ativado.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Ramifica o fluxo de trabalho com base em uma condição.", "inspector_unsupported_node": "Este tipo de nó ainda não tem um formulário de configuração.", "load_failed": "Não foi possível carregar o fluxo de trabalho.", + "name_invalid": "O nome deve ter entre 1 e 120 caracteres.", "name_required": "Insira um nome.", "no_results_description": "Tente ajustar sua busca ou filtros.", "no_results_title": "Nenhum workflow encontrado", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index f0a3e1a9060a..fc6f421e3ff8 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos", "plan_scale_feature_security": "2FA e proteção contra spam", "plan_scale_feature_semantic_analysis": "Análise Semântica (IA)", - "plan_scale_feature_workflows": "Fluxos de Trabalho", + "plan_scale_feature_workflow_runs": "1000 execuções de fluxo de trabalho / mês com preços dinâmicos", "plan_scale_feature_workspaces": "5 áreas de trabalho", "plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.", "plan_selection_title": "Escolhe o teu plano", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Desbloquear todas as funcionalidades", "trial_warning_remind_me_later": "Lembrar-me mais tarde", "unlimited_responses": "Respostas Ilimitadas", + "unlimited_workflow_runs": "Execuções de Fluxo de Trabalho Ilimitadas", "unlimited_workspaces": "Espaços de Trabalho Ilimitados", "unlock_all_plan_features": "Desbloquear todas as funcionalidades do {plan}", "upgrade": "Atualizar", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Tens a certeza de que queres arquivar \"{name}\"? Podes restaurá-lo mais tarde.", "archive_workflow_description": "Arquivar oculta o fluxo de trabalho da lista. Podes restaurá-lo mais tarde.", "auto_layout": "Disposição automática", + "autosave_blocked": "Não guardado", + "autosave_blocked_tooltip": "As tuas alterações não estão a ser guardadas. Dá um nome ao fluxo de trabalho para continuar.", "autosave_failed": "Falha ao guardar", "autosave_failed_tooltip": "Não foi possível guardar as tuas últimas alterações. Verifica a tua ligação e tenta novamente.", "autosave_failed_tooltip_rejected": "Não foi possível guardar as tuas últimas alterações: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Enviar para", "email_to_placeholder": "equipa@exemplo.com", "email_to_required": "Escolhe quem deve receber este email.", + "email_to_unavailable_group_label": "Não disponível", + "email_to_unavailable_warning": "Este destinatário já não está disponível — pode ter perdido o acesso a esta área de trabalho ou o campo de inquérito de onde provinha foi removido. Escolhe um novo; este email não pode ser enviado enquanto não o fizeres.", "enable_blocked_unsaved_changes": "Não foi possível guardar as tuas últimas alterações, por isso o workflow não foi ativado.", "enable_failed": "Não foi possível ativar o fluxo de trabalho.", "enable_success": "Fluxo de trabalho ativado.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Ramifica o fluxo de trabalho com base numa condição.", "inspector_unsupported_node": "Este tipo de nó ainda não tem um formulário de configuração.", "load_failed": "Não foi possível carregar o fluxo de trabalho.", + "name_invalid": "O nome deve ter entre 1 e 120 caracteres.", "name_required": "Introduza um nome.", "no_results_description": "Tenta ajustar a tua pesquisa ou filtros.", "no_results_title": "Nenhum fluxo de trabalho encontrado", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index f8c2ba12c0bd..74c7cc42bba3 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5.000 de răspunsuri / lună cu prețuri dinamice", "plan_scale_feature_security": "2FA și protecție împotriva spam-ului", "plan_scale_feature_semantic_analysis": "Analiză semantică (AI)", - "plan_scale_feature_workflows": "Fluxuri de lucru", + "plan_scale_feature_workflow_runs": "1.000 de rulări de fluxuri de lucru / lună cu prețuri dinamice", "plan_scale_feature_workspaces": "5 spații de lucru", "plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.", "plan_selection_title": "Alege-ți planul", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Deblochează toate funcțiile", "trial_warning_remind_me_later": "Amintește-mi mai târziu", "unlimited_responses": "Răspunsuri nelimitate", + "unlimited_workflow_runs": "Rulări nelimitate de fluxuri de lucru", "unlimited_workspaces": "Workspaces nelimitate", "unlock_all_plan_features": "Deblochează toate funcțiile {plan}", "upgrade": "Actualizare", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Ești sigur că vrei să arhivezi \"{name}\"? Îl poți restabili mai târziu.", "archive_workflow_description": "Arhivarea ascunde workflow-ul din listă. Îl poți restabili mai târziu.", "auto_layout": "Aranjare automată", + "autosave_blocked": "Nesalvat", + "autosave_blocked_tooltip": "Modificările tale nu sunt salvate. Dă un nume fluxului de lucru pentru a continua.", "autosave_failed": "Salvarea a eșuat", "autosave_failed_tooltip": "Ultimele modificări nu au putut fi salvate. Verifică conexiunea și încearcă din nou.", "autosave_failed_tooltip_rejected": "Ultimele tale modificări nu au putut fi salvate: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Trimite către", "email_to_placeholder": "echipa@exemplu.com", "email_to_required": "Alege cine ar trebui să primească acest email.", + "email_to_unavailable_group_label": "Indisponibil", + "email_to_unavailable_warning": "Acest destinatar nu mai este disponibil — este posibil să fi pierdut accesul la acest spațiu de lucru sau câmpul sondajului din care provenea a fost eliminat. Alege unul nou; acest email nu poate fi trimis până nu faci asta.", "enable_blocked_unsaved_changes": "Ultimele tale modificări nu au putut fi salvate, așa că fluxul de lucru nu a fost activat.", "enable_failed": "Nu am putut activa workflow-ul.", "enable_success": "Workflow activat.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Ramifică fluxul de lucru pe baza unei condiții.", "inspector_unsupported_node": "Acest tip de nod nu are încă un formular de configurare.", "load_failed": "Nu am putut încărca fluxul de lucru.", + "name_invalid": "Numele trebuie să aibă între 1 și 120 de caractere.", "name_required": "Introduceți un nume.", "no_results_description": "Încearcă să ajustezi căutarea sau filtrele.", "no_results_title": "Nu s-au găsit fluxuri de lucru", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 0a5d35515da8..ebe0cab655c4 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5 000 ответов в месяц с динамическим ценообразованием", "plan_scale_feature_security": "Двухфакторная аутентификация и защита от спама", "plan_scale_feature_semantic_analysis": "Семантический анализ (AI)", - "plan_scale_feature_workflows": "Рабочие процессы", + "plan_scale_feature_workflow_runs": "1 000 запусков процессов в месяц с динамическим ценообразованием", "plan_scale_feature_workspaces": "5 рабочих пространств", "plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.", "plan_selection_title": "Выбери свой план", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Разблокировать все функции", "trial_warning_remind_me_later": "Напомнить позже", "unlimited_responses": "Неограниченное количество ответов", + "unlimited_workflow_runs": "Неограниченное количество запусков рабочих процессов", "unlimited_workspaces": "Неограниченное количество рабочих пространств", "unlock_all_plan_features": "Разблокировать все функции тарифа {plan}", "upgrade": "Обновить", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Точно хочешь архивировать «{name}»? Ты сможешь восстановить его позже.", "archive_workflow_description": "Архивирование скрывает воркфлоу из списка. Ты сможешь восстановить его позже.", "auto_layout": "Автораскладка", + "autosave_blocked": "Не сохранено", + "autosave_blocked_tooltip": "Ваши изменения не сохраняются. Дайте workflow имя, чтобы продолжить.", "autosave_failed": "Не удалось сохранить", "autosave_failed_tooltip": "Не удалось сохранить последние изменения. Проверьте подключение к интернету и попробуйте снова.", "autosave_failed_tooltip_rejected": "Последние изменения не удалось сохранить: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Отправить на", "email_to_placeholder": "team@example.com", "email_to_required": "Укажите, кто должен получить это письмо.", + "email_to_unavailable_group_label": "Недоступно", + "email_to_unavailable_warning": "Этот получатель больше недоступен — возможно, он потерял доступ к этому рабочему пространству или поле опроса, из которого он был выбран, было удалено. Выберите нового получателя; это письмо не может быть отправлено, пока вы этого не сделаете.", "enable_blocked_unsaved_changes": "Последние изменения не удалось сохранить, поэтому процесс не был включён.", "enable_failed": "Не удалось включить рабочий процесс.", "enable_success": "Рабочий процесс включен.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Разветвить workflow на основе условия.", "inspector_unsupported_node": "Для этого типа узла пока нет формы настройки.", "load_failed": "Не удалось загрузить workflow.", + "name_invalid": "Имя должно содержать от 1 до 120 символов.", "name_required": "Введите название.", "no_results_description": "Попробуйте изменить поисковый запрос или фильтры.", "no_results_title": "Рабочие процессы не найдены", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index d450a17d34cf..880ca6e98a18 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "5 000 svar / månad med dynamisk prissättning", "plan_scale_feature_security": "2FA och skräppostskydd", "plan_scale_feature_semantic_analysis": "Semantisk analys (AI)", - "plan_scale_feature_workflows": "Arbetsflöden", + "plan_scale_feature_workflow_runs": "1 000 arbetsflödeskörningar/månad med dynamisk prissättning", "plan_scale_feature_workspaces": "5 arbetsytor", "plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.", "plan_selection_title": "Välj din plan", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Lås upp alla funktioner", "trial_warning_remind_me_later": "Påminn mig senare", "unlimited_responses": "Obegränsade svar", + "unlimited_workflow_runs": "Obegränsade arbetsflödeskörningar", "unlimited_workspaces": "Obegränsat antal arbetsytor", "unlock_all_plan_features": "Lås upp alla {plan}-funktioner", "upgrade": "Uppgradera", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "Är du säker på att du vill arkivera \"{name}\"? Du kan återställa det senare.", "archive_workflow_description": "Arkivering döljer arbetsflödet från listan. Du kan återställa det senare.", "auto_layout": "Automatisk layout", + "autosave_blocked": "Inte sparad", + "autosave_blocked_tooltip": "Dina ändringar sparas inte. Ge arbetsflödet ett namn för att fortsätta.", "autosave_failed": "Sparande misslyckades", "autosave_failed_tooltip": "Dina senaste ändringar kunde inte sparas. Kontrollera din anslutning och försök igen.", "autosave_failed_tooltip_rejected": "Dina senaste ändringar kunde inte sparas: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Skicka till", "email_to_placeholder": "team@example.com", "email_to_required": "Välj vem som ska ta emot det här mejlet.", + "email_to_unavailable_group_label": "Inte tillgänglig", + "email_to_unavailable_warning": "Den här mottagaren är inte längre tillgänglig — de kan ha förlorat åtkomst till den här arbetsytan, eller så har enkätfältet de kom från tagits bort. Välj en ny mottagare; det här e-postmeddelandet kan inte skickas förrän du gör det.", "enable_blocked_unsaved_changes": "Dina senaste ändringar kunde inte sparas, så arbetsflödet aktiverades inte.", "enable_failed": "Kunde inte aktivera arbetsflödet.", "enable_success": "Arbetsflöde aktiverat.", @@ -4231,6 +4236,7 @@ "if_else_summary": "Förgrena arbetsflödet baserat på ett villkor.", "inspector_unsupported_node": "Den här nodtypen har inte något konfigurationsformulär än.", "load_failed": "Kunde inte ladda arbetsflödet.", + "name_invalid": "Namnet måste vara mellan 1 och 120 tecken.", "name_required": "Ange ett namn.", "no_results_description": "Prova att justera din sökning eller dina filter.", "no_results_title": "Inga arbetsflöden hittades", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 65e166bff8b3..750f5d75eed0 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "Ayda 5.000 yanıt, dinamik fiyatlandırma ile", "plan_scale_feature_security": "2FA ve spam koruması", "plan_scale_feature_semantic_analysis": "Anlamsal Analiz (Yapay Zeka)", - "plan_scale_feature_workflows": "İş Akışları", + "plan_scale_feature_workflow_runs": "Ayda 1.000 iş akışı çalıştırması ile dinamik fiyatlandırma", "plan_scale_feature_workspaces": "5 çalışma alanı", "plan_selection_description": "Hobby, Pro ve Scale planlarını karşılaştır, ardından doğrudan Formbricks'ten plan değiştir.", "plan_selection_title": "Planını seç", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "Tüm özelliklerin kilidini aç", "trial_warning_remind_me_later": "Daha sonra hatırlat", "unlimited_responses": "Sınırsız Yanıt", + "unlimited_workflow_runs": "Sınırsız İş Akışı Çalıştırması", "unlimited_workspaces": "Sınırsız Çalışma Alanı", "unlock_all_plan_features": "Tüm {plan} özelliklerinin kilidini aç", "upgrade": "Yükselt", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "\"{name}\" iş akışını arşivlemek istediğinizden emin misiniz? Daha sonra geri yükleyebilirsiniz.", "archive_workflow_description": "Arşivleme, iş akışını listeden gizler. Daha sonra geri yükleyebilirsiniz.", "auto_layout": "Otomatik düzen", + "autosave_blocked": "Kaydedilmedi", + "autosave_blocked_tooltip": "Değişiklikleriniz kaydedilmiyor. Devam etmek için iş akışına bir isim ver.", "autosave_failed": "Kaydetme başarısız oldu", "autosave_failed_tooltip": "Son değişiklikleriniz kaydedilemedi. Bağlantınızı kontrol edin ve tekrar deneyin.", "autosave_failed_tooltip_rejected": "Son değişiklikleriniz kaydedilemedi: {detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "Gönder", "email_to_placeholder": "ekip@ornek.com", "email_to_required": "Bu e-postayı kimin alacağını seçin.", + "email_to_unavailable_group_label": "Kullanılamıyor", + "email_to_unavailable_warning": "Bu alıcı artık kullanılamıyor — bu çalışma alanına erişimini kaybetmiş olabilir veya geldiği anket alanı kaldırılmış olabilir. Yeni bir tane seç; bunu yapana kadar bu e-posta gönderilemez.", "enable_blocked_unsaved_changes": "Son değişiklikleriniz kaydedilemediği için iş akışı etkinleştirilemedi.", "enable_failed": "İş akışı etkinleştirilemedi.", "enable_success": "İş akışı etkinleştirildi.", @@ -4231,6 +4236,7 @@ "if_else_summary": "İş akışını bir koşula göre dallandır.", "inspector_unsupported_node": "Bu düğüm türü henüz bir yapılandırma formuna sahip değil.", "load_failed": "İş akışı yüklenemedi.", + "name_invalid": "İsim 1 ile 120 karakter arasında olmalıdır.", "name_required": "Lütfen bir ad girin.", "no_results_description": "Aramayı veya filtreleri ayarlamayı dene.", "no_results_title": "İş akışı bulunamadı", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index ee1b8c4cba03..4c8f2424c7bc 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "每月 5,000 次响应,采用动态定价", "plan_scale_feature_security": "双因素认证和垃圾邮件防护", "plan_scale_feature_semantic_analysis": "语义分析(AI)", - "plan_scale_feature_workflows": "工作流", + "plan_scale_feature_workflow_runs": "每月 1,000 次工作流运行,采用动态定价", "plan_scale_feature_workspaces": "5 个工作区", "plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。", "plan_selection_title": "选择您的套餐", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "解锁全部功能", "trial_warning_remind_me_later": "稍后提醒我", "unlimited_responses": "无限反馈", + "unlimited_workflow_runs": "无限工作流运行次数", "unlimited_workspaces": "无限工作区", "unlock_all_plan_features": "解锁所有 {plan} 功能", "upgrade": "升级", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "确定要归档“{name}”吗?你可以稍后恢复它。", "archive_workflow_description": "归档后,工作流会从列表中隐藏。你可以随时恢复。", "auto_layout": "自动布局", + "autosave_blocked": "未保存", + "autosave_blocked_tooltip": "你的更改未被保存。请为工作流命名以继续。", "autosave_failed": "保存失败", "autosave_failed_tooltip": "无法保存你的最新更改。请检查网络连接并重试。", "autosave_failed_tooltip_rejected": "无法保存你的最新更改:{detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "发送至", "email_to_placeholder": "team@example.com", "email_to_required": "选择应接收此邮件的收件人。", + "email_to_unavailable_group_label": "不可用", + "email_to_unavailable_warning": "此收件人不再可用——他们可能已失去对此工作区的访问权限,或者他们所属的调查字段已被删除。请选择新的收件人;在此之前无法发送此邮件。", "enable_blocked_unsaved_changes": "无法保存你的最新更改,因此工作流未启用。", "enable_failed": "无法启用工作流。", "enable_success": "工作流已启用。", @@ -4231,6 +4236,7 @@ "if_else_summary": "根据条件分支工作流。", "inspector_unsupported_node": "此节点类型暂无配置表单。", "load_failed": "无法加载工作流。", + "name_invalid": "名称长度必须在 1 到 120 个字符之间。", "name_required": "请输入名称。", "no_results_description": "试试调整你的搜索或筛选条件。", "no_results_title": "未找到工作流", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 4d0c98dd955e..95357035da54 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -2581,7 +2581,7 @@ "plan_scale_feature_responses": "每月 5,000 次回應,採用動態定價", "plan_scale_feature_security": "雙因素驗證與垃圾訊息防護", "plan_scale_feature_semantic_analysis": "語義分析(AI)", - "plan_scale_feature_workflows": "工作流程", + "plan_scale_feature_workflow_runs": "每月 1,000 次工作流程執行,採用動態定價", "plan_scale_feature_workspaces": "5 個工作區", "plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。", "plan_selection_title": "選擇您的方案", @@ -2622,6 +2622,7 @@ "trial_warning_add_payment_method": "解鎖所有功能", "trial_warning_remind_me_later": "稍後提醒我", "unlimited_responses": "無限回應", + "unlimited_workflow_runs": "無限制工作流程執行次數", "unlimited_workspaces": "無限工作區", "unlock_all_plan_features": "解鎖所有 {plan} 功能", "upgrade": "升級", @@ -4193,6 +4194,8 @@ "archive_workflow_confirmation": "確定要封存「{name}」嗎?你之後可以還原它。", "archive_workflow_description": "封存會將工作流程從列表中隱藏,你之後可以還原它。", "auto_layout": "自動排版", + "autosave_blocked": "未儲存", + "autosave_blocked_tooltip": "你的變更尚未儲存。請為工作流程命名以繼續。", "autosave_failed": "儲存失敗", "autosave_failed_tooltip": "無法儲存你的最新變更。請檢查你的連線並再試一次。", "autosave_failed_tooltip_rejected": "無法儲存你的最新變更:{detail}", @@ -4223,6 +4226,8 @@ "email_to_label": "傳送至", "email_to_placeholder": "team@example.com", "email_to_required": "選擇誰應該收到這封電子郵件。", + "email_to_unavailable_group_label": "無法使用", + "email_to_unavailable_warning": "此收件者已無法使用——他們可能已失去此工作區的存取權限,或他們來自的調查問卷欄位已被移除。請選擇新的收件者;在您更換之前,這封電子郵件無法寄送。", "enable_blocked_unsaved_changes": "無法儲存你的最新變更,因此工作流程未啟用。", "enable_failed": "無法啟用工作流程。", "enable_success": "工作流程已啟用。", @@ -4231,6 +4236,7 @@ "if_else_summary": "根據條件分支工作流程。", "inspector_unsupported_node": "此節點類型尚未提供設定表單。", "load_failed": "無法載入工作流程。", + "name_invalid": "名稱必須介於 1 到 120 個字元之間。", "name_required": "請輸入名稱。", "no_results_description": "試著調整你的搜尋條件或篩選器。", "no_results_title": "找不到工作流程", diff --git a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx index b448f649965b..4193da2d6d67 100644 --- a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx +++ b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx @@ -3,7 +3,7 @@ import { type ElementType, type ReactNode } from "react"; import { CartesianGrid, XAxis, YAxis } from "recharts"; import { formatXAxisTick } from "@/modules/ee/analysis/charts/lib/chart-utils"; -import { computeYAxis } from "@/modules/ee/analysis/charts/lib/y-axis-scale"; +import { type YAxisScale, computeYAxis } from "@/modules/ee/analysis/charts/lib/y-axis-scale"; import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; import type { ChartConfig } from "@/modules/ui/components/chart"; import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip } from "@/modules/ui/components/chart"; @@ -31,6 +31,10 @@ export interface CartesianChartProps { * measure charts keep their category axis but hide the header, since each tooltip row already * carries the measure label and a header would just repeat it. */ tooltipHideLabel?: boolean; + /** Precomputed Y-axis scale, used in place of deriving one from `data`/`dataKeys`. Measure-pivot + * charts render values under a synthetic key (PIVOTED_VALUE_KEY) that carries no measure id, so + * they resolve the fixed-scale axis from the original measure columns and pass it here (ENG-2226). */ + yAxisScale?: YAxisScale; } export function CartesianChart({ @@ -47,8 +51,9 @@ export function CartesianChart({ xAxisTickFormatter, hasCategoryAxis = true, tooltipHideLabel, + yAxisScale, }: Readonly) { - const yScale = computeYAxis(data, dataKeys, zeroBaseline); + const yScale = yAxisScale ?? computeYAxis(data, dataKeys, zeroBaseline); return (
diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index 88c673851746..03ebcc89a8ed 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -21,6 +21,7 @@ import { prepareMeasureSliceData, preparePieData, } from "@/modules/ee/analysis/charts/lib/chart-utils"; +import { computeYAxis } from "@/modules/ee/analysis/charts/lib/y-axis-scale"; import { FEEDBACK_MEASURE_IDS, formatCubeColumnHeader, @@ -156,6 +157,12 @@ const BarChartView = ({ const measureData = pivotMeasuresToCategories(sortedData, axisKeys, (key) => formatCubeColumnHeader(key, t) ); + // Pivoting collapses every measure onto PIVOTED_VALUE_KEY ("value"), which carries no measure + // id, so an axis derived from the pivoted rows can't look up fixed-scale candidates and falls + // back to data-driven "nice" bounds. Resolve the scale from the original measure columns so + // rating/CSAT/CES/NPS averages still pin to the question scale, e.g. 3.3 on a 1-5 rating tops + // the axis at 5, not 4 (ENG-2226). + const yAxisScale = computeYAxis(sortedData, dataKeys, true); // Ticks use the short value label ("Very positive") — the full measure label is too // wide, so recharts would thin the ticks and bars would lose their name. The tooltip // keeps the full label via tooltipLabel. @@ -167,6 +174,7 @@ const BarChartView = ({ data={measureData} xAxisKey={PIVOTED_MEASURE_KEY} dataKeys={[PIVOTED_VALUE_KEY]} + yAxisScale={yAxisScale} chartConfig={chartConfig} tooltipCursor={false} zeroBaseline diff --git a/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts b/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts index dfea6c4d1a0c..975faaf251a7 100644 --- a/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts @@ -58,6 +58,17 @@ describe("computeYAxis", () => { const data = [{ [RATING_AVG]: 3.2, [CSAT_AVG]: null }]; expect(computeYAxis(data, [RATING_AVG, CSAT_AVG], true)?.domain).toEqual([0, 5]); }); + + // Regression for ENG-2226: ungrouped measure charts pivot every measure onto a synthetic + // "value" key that resolves no candidates, so pinning must key off the real measure id. The + // renderer therefore derives the axis from the original measure columns, not the pivoted key. + test("pinning keys off the real measure id, not a synthetic pivot key", () => { + const PIVOTED_VALUE_KEY = "value"; + // Same 1-5 rating average of 3.3: keyed by the measure id it pins to 5; keyed by the + // synthetic pivot key it can't look up candidates and falls back to a data-driven 4. + expect(computeYAxis([{ [RATING_AVG]: 3.3 }], [RATING_AVG], true)?.domain).toEqual([0, 5]); + expect(computeYAxis([{ [PIVOTED_VALUE_KEY]: 3.3 }], [PIVOTED_VALUE_KEY], true)?.domain).toEqual([0, 4]); + }); }); describe("falls back to data-driven nice scaling", () => { diff --git a/apps/web/modules/ee/billing/RUNBOOK-workflows-metering-prod.md b/apps/web/modules/ee/billing/RUNBOOK-workflows-metering-prod.md new file mode 100644 index 000000000000..aa680d76013a --- /dev/null +++ b/apps/web/modules/ee/billing/RUNBOOK-workflows-metering-prod.md @@ -0,0 +1,81 @@ +# Runbook — Enable metered workflow runs on prod (ENG-1936 / ENG-2193 / ENG-2194) + +Internal ops runbook. Turns on billing for workflow runs on Cloud (livemode Stripe) after PR #8735. +Staging already has this; prod does not. + +## Background (how it works) + +Three Stripe objects drive the app per Scale org: + +- Availability entitlement — product feature lookup key **`workflows`** → `getIsWorkflowsEnabled(orgId)`. +- Included-volume entitlement — product feature **`workflow-runs-included-1000`** → `limits.monthly.workflowRuns`. +- Metered price — `formbricks_price_kind: workflow_runs`, attached to meter **`workflow_run_created`**. + +Key facts: + +- **Attaching a price to a product does NOT add it to existing subs.** Sub line items come from the app's + `getCatalogItemsForPlan`; only new checkouts + plan changes pick up the workflow line item. +- **Entitlements (product features) auto-propagate** to every active subscriber. DB limits refresh on the + next billing **sync** (webhook `customer.subscription.*` / `invoice.*` → `syncOrganizationBillingFromStripe`). +- **`reconcileCloudStripeSubscriptionsForOrganization` does NOT backfill line items** — existing Scale subs + must be backfilled manually. +- The usage card's "included" number is the **price's free-tier boundary** (global catalog), not the + entitlement. **Fail-closed (workflow item only):** if the workflow price is not graduated with a free + first tier (finite `up_to`), the workflow item is dropped from the catalog (card hides, new subs skip + it — workflows just don't bill) and a loud `logger.error` fires. The rest of billing (base/responses + checkout, billing page) stays up. Watch logs for `Invalid workflow_runs price`. + +## Rollout — do in order (order matters, fail-closed) + +1. **Merge + deploy PR #8735.** Before a price exists, the catalog resolves the workflow price to null → + card hidden, no line item added. No breakage. Deploy first so provisioning + fail-closed validation are + live before any price appears. + +2. **Create the meter** (prod livemode): event name **`workflow_run_created`**, display "Workflow runs". + +3. **Create the metered price** on the Scale product (`prod_...` livemode): + - `usage_type: metered`, attached to the meter from step 2. + - **Graduated, first tier `unit_amount: 0` up to `1000`** (this boundary is what the card shows as + "included"), then overage tier(s) per pricing. + - metadata: `formbricks_price_kind: workflow_runs`, `formbricks_interval: monthly`. + - Exactly **one** active such price per plan (a duplicate → catalog throws "found 2"). + +4. **Attach 2 product features** to the Scale product: + - `workflows` (availability). + - `workflow-runs-included-1000` (included volume; boundary must match the price's free tier = 1000). + +5. **Backfill existing Scale subs** — one metered line item each (no quantity): + ``` + stripe subscription_items create --subscription --price + ``` + Script over every active Scale sub. Idempotent: skip subs that already have the workflow price. + +6. **Refresh DB limits** for existing orgs: step 5 fires `customer.subscription.updated` → sync runs + automatically. Otherwise trigger a forced sync per org. + +## How it starts working for existing Scale customers + +| Concern | Auto / manual | Reflects when | +| --- | --- | --- | +| Availability (`workflows`) | Auto — feature grants entitlement to all active subscribers | Next billing sync | +| Included-volume limit | Auto — same | Next billing sync | +| Usage card shows | Auto — reads global catalog price free-tier | Price active + code deployed | +| Billing (runs → invoice) | **Manual** — line item not auto-added (reconcile doesn't backfill) | After step 5 | + +Until step 5, an existing customer's runs are metered but have no subscription item → **not invoiced**. +That gap is exactly what the backfill closes. + +## Verify + +- New Scale checkout → sub has 3 items (base + responses + workflow_runs); billing page shows + "Workflow Runs 0 of 1000", no error. +- Pick 1 existing (backfilled) customer → card shows included; a real run meters; upcoming invoice = $0 + within 1000, charges only overage past it. + +## Guardrails + +- Price MUST be graduated with a free first tier. If not, the workflow item is silently dropped (card + hidden, not billed on new subs) and a `logger.error` ("Invalid workflow_runs price") fires — the rest + of billing stays up. Monitor logs; a bad price means workflows stop billing, not an outage. +- Never two active workflow prices per plan/interval (that DOES break the catalog — `found 2`). +- Deploy code before creating the price. diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx index e411ddbc7524..a1298985beb4 100644 --- a/apps/web/modules/ee/billing/components/pricing-table.tsx +++ b/apps/web/modules/ee/billing/components/pricing-table.tsx @@ -1,6 +1,7 @@ "use client"; import { type Stripe as StripeJs, loadStripe } from "@stripe/stripe-js"; +import type { TFunction } from "i18next"; import { CheckIcon } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -34,9 +35,12 @@ import { waitForBillingPaymentMethodAction, waitForBillingPlanAction, } from "../actions"; -import type { TStripeBillingCatalogDisplay } from "../lib/stripe-billing-catalog"; +import type { + TStripeBillingCatalogDisplay, + TStripeBillingCatalogDisplayItem, +} from "../lib/stripe-billing-catalog"; import { PlanComparisonTable, type TPlanColumn } from "./plan-comparison"; -import { PlanResponseFeature } from "./response-pricing-tooltip"; +import { PlanResponseFeature, PlanWorkflowRunsFeature } from "./response-pricing-tooltip"; import { TrialAlert } from "./trial-alert"; import { UsageCard } from "./usage-card"; @@ -66,6 +70,7 @@ interface PricingTableProps { organization: TOrganization; responseCount: number; workspaceCount: number; + workflowRunCount: number; isPlanComparison: boolean; usageCycleStart: Date; usageCycleEnd: Date; @@ -86,6 +91,20 @@ const STANDARD_PLAN_LEVEL: Record = { scale: 2, }; +// Billing-catalog display item for the org's current plan/interval, or null for plans not in the +// standard catalog (custom/unknown). Kept out of the component to avoid a nested ternary and hold the +// component's cognitive complexity down. +const getCurrentPlanCatalogItem = ( + billingCatalog: TStripeBillingCatalogDisplay, + currentCloudPlan: TDisplayPlan, + interval: TCloudBillingInterval +): TStripeBillingCatalogDisplayItem | null => { + if (currentCloudPlan === "hobby") return billingCatalog.hobby.monthly; + if (currentCloudPlan === "pro") return billingCatalog.pro[interval]; + if (currentCloudPlan === "scale") return billingCatalog.scale[interval]; + return null; +}; + const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string) => { if (plan === "hobby") return t("workspace.settings.billing.plan_hobby"); if (plan === "pro") return t("workspace.settings.billing.plan_pro"); @@ -106,7 +125,10 @@ const formatMoney = (currency: string, unitAmount: number | null, locale: string }).format(unitAmount / 100); }; -type TPlanFeature = { type: "text"; label: string } | { type: "responses"; plan: "pro" | "scale" }; +type TPlanFeature = + | { type: "text"; label: string } + | { type: "responses"; plan: "pro" | "scale" } + | { type: "workflow_runs"; plan: "scale" }; type TPlanCardData = { plan: TStandardPlan; @@ -233,10 +255,51 @@ const isSwitchAtPeriodEndCta = ( return STANDARD_PLAN_LEVEL[plan] <= currentPlanLevel; }; +// Renders one plan-card feature row. Metered features (responses, workflow runs) show a tier tooltip +// sourced from the catalog; plain text features just render their label. Extracted from PricingTable +// to keep that component's cognitive complexity within bounds. +const PlanFeatureContent = ({ + feature, + billingCatalog, + selectedInterval, + locale, + t, +}: Readonly<{ + feature: TPlanFeature; + billingCatalog: TStripeBillingCatalogDisplay; + selectedInterval: TCloudBillingInterval; + locale: string; + t: TFunction; +}>) => { + if (feature.type === "text") { + return <>{feature.label}; + } + + if (feature.type === "responses") { + return ( + + ); + } + + return ( + + ); +}; + export const PricingTable = ({ organization, responseCount, workspaceCount, + workflowRunCount, isPlanComparison, usageCycleStart, usageCycleEnd, @@ -308,6 +371,16 @@ export const PricingTable = ({ })}`; const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null; const workspacesUnlimitedCheck = organization.billing.limits.workspaces === null; + // The workflow-runs card's included volume comes from the billing catalog (derived from the price's + // own free tier), NOT the entitlement limit, so the number shown is exactly what Stripe leaves + // uncharged — the two can't drift and reassure a customer they're inside an allowance while being + // billed (ENG-2193/2194). Null when the current plan has no workflow price, so the card hides. + const currentPlanCatalogItem = getCurrentPlanCatalogItem( + billingCatalog, + currentCloudPlan, + currentBillingInterval ?? "monthly" + ); + const workflowRunsLimit = currentPlanCatalogItem?.workflowRunsIncluded ?? null; const trialEndDate = organization.billing.stripe?.trialEnd ? new Date(organization.billing.stripe.trialEnd) : null; @@ -641,7 +714,7 @@ export const PricingTable = ({ { type: "text", label: t("workspace.settings.billing.plan_scale_feature_workspaces") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_rbac") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_quota") }, - { type: "text", label: t("workspace.settings.billing.plan_scale_feature_workflows") }, + { type: "workflow_runs", plan: "scale" }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_feedback") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_semantic_analysis") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_security") }, @@ -1274,20 +1347,17 @@ export const PricingTable = ({
    {planCard.features.map((feature) => (
  • - {feature.type === "text" ? ( - feature.label - ) : ( - - )} +
  • ))} @@ -1421,6 +1491,16 @@ export const PricingTable = ({

+ {workflowRunsLimit != null && ( + + )} + ); }; + +interface PlanWorkflowRunsFeatureProps { + locale: string; + overage: TResponseOverageDisplay | null; + t: TFunction; +} + +// Workflow runs are metered graduated like responses (Scale only). Same tier tooltip, sourced from +// the workflow price's tiers so the shown pricing matches what Stripe charges (ENG-2194). +export const PlanWorkflowRunsFeature = ({ locale, overage, t }: Readonly) => { + return ( + + ); +}; diff --git a/apps/web/modules/ee/billing/lib/organization-billing.ts b/apps/web/modules/ee/billing/lib/organization-billing.ts index df0ea7b128cd..747de5652c19 100644 --- a/apps/web/modules/ee/billing/lib/organization-billing.ts +++ b/apps/web/modules/ee/billing/lib/organization-billing.ts @@ -584,9 +584,11 @@ export const createPaidPlanCheckoutSession = async (input: { const catalogItem = await getCatalogItemForPlan(input.plan, input.interval); const checkoutIntervals = new Set( - [catalogItem.basePrice.recurring?.interval, catalogItem.responsePrice?.recurring?.interval].filter( - (interval): interval is Stripe.Price.Recurring.Interval => interval != null - ) + [ + catalogItem.basePrice.recurring?.interval, + catalogItem.responsePrice?.recurring?.interval, + catalogItem.workflowRunsPrice?.recurring?.interval, + ].filter((interval): interval is Stripe.Price.Recurring.Interval => interval != null) ); if (checkoutIntervals.size > 1) { @@ -804,6 +806,7 @@ const getScheduleItemsForPlanChange = async ( const targetItems = mapSubscriptionItemsToScheduleItems([ { price: targetCatalogItem.basePrice, quantity: 1 }, ...(targetCatalogItem.responsePrice ? [{ price: targetCatalogItem.responsePrice }] : []), + ...(targetCatalogItem.workflowRunsPrice ? [{ price: targetCatalogItem.workflowRunsPrice }] : []), ]); return { currentItems, targetItems }; diff --git a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts index 779d9c7dc19e..5424103dad1d 100644 --- a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts +++ b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts @@ -7,6 +7,11 @@ const TEST_TIMEOUT_MS = 15_000; const mocks = vi.hoisted(() => ({ pricesList: vi.fn(), cacheWithCache: vi.fn(), + loggerError: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { error: mocks.loggerError }, })); vi.mock("./stripe-client", () => ({ @@ -76,6 +81,34 @@ const createPrice = ({ }, }); +// A Scale metered workflow-run price (ENG-1936). Its metadata kind is what keeps it from being +// misread as "responses" by the usage_type fallback. +const createWorkflowRunsPrice = (id: string) => ({ + id, + active: true, + currency: "usd", + unit_amount: 0, + tiers: RESPONSE_PRICE_TIERS, + tiers_mode: "graduated", + metadata: { + formbricks_plan: "scale", + formbricks_price_kind: "workflow_runs", + formbricks_interval: "monthly", + }, + recurring: { usage_type: "metered", interval: "month" }, + product: { id: "prod_scale", active: true, metadata: { formbricks_plan: "scale" } }, +}); + +const STANDARD_CATALOG_PRICES = [ + createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }), + createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }), + createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }), + createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }), + createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }), + createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }), + createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }), +]; + describe("stripe-billing-catalog", () => { beforeEach(() => { vi.clearAllMocks(); @@ -127,6 +160,8 @@ describe("stripe-billing-catalog", () => { currency: "usd", unitAmount: 1000, responseOverage: null, + workflowRunsIncluded: null, + workflowRunsOverage: null, }, }, pro: { @@ -136,6 +171,8 @@ describe("stripe-billing-catalog", () => { currency: "usd", unitAmount: 1000, responseOverage: EXPECTED_RESPONSE_OVERAGE, + workflowRunsIncluded: null, + workflowRunsOverage: null, }, yearly: { plan: "pro", @@ -143,6 +180,8 @@ describe("stripe-billing-catalog", () => { currency: "usd", unitAmount: 10000, responseOverage: EXPECTED_RESPONSE_OVERAGE, + workflowRunsIncluded: null, + workflowRunsOverage: null, }, }, scale: { @@ -152,6 +191,8 @@ describe("stripe-billing-catalog", () => { currency: "usd", unitAmount: 1000, responseOverage: EXPECTED_RESPONSE_OVERAGE, + workflowRunsIncluded: null, + workflowRunsOverage: null, }, yearly: { plan: "scale", @@ -159,6 +200,8 @@ describe("stripe-billing-catalog", () => { currency: "usd", unitAmount: 10000, responseOverage: EXPECTED_RESPONSE_OVERAGE, + workflowRunsIncluded: null, + workflowRunsOverage: null, }, }, }); @@ -198,48 +241,146 @@ describe("stripe-billing-catalog", () => { ); test( - "excludes a metered price tagged with a non-responses kind (e.g. workflow_runs) so it does not collide with responses", + "provisions a workflow_runs metered price as its own line item without colliding with responses", async () => { - // A second metered price on Scale, tagged workflow_runs (ENG-1936). Without the getPriceKind - // guard it would fall through usage_type=metered -> "responses" and produce two matches for - // scale/responses/monthly, breaking the billing page. - const workflowRunsPrice = { - id: "price_scale_workflow_runs", - active: true, - currency: "usd", - unit_amount: 0, - tiers: RESPONSE_PRICE_TIERS, - tiers_mode: "graduated", - metadata: { - formbricks_plan: "scale", - formbricks_price_kind: "workflow_runs", - formbricks_interval: "monthly", - }, - recurring: { usage_type: "metered", interval: "month" }, - product: { id: "prod_scale", active: true, metadata: { formbricks_plan: "scale" } }, - }; + // A second metered price on Scale, tagged workflow_runs (ENG-1936). getPriceKind classifies it + // by its metadata kind rather than the usage_type=metered fallback, so it does NOT produce a + // second match for scale/responses/monthly (which would throw "found 2"), and it is added to the + // subscription as its own metered line item. + mocks.pricesList.mockResolvedValue({ + data: [...STANDARD_CATALOG_PRICES, createWorkflowRunsPrice("price_scale_workflow_runs")], + has_more: false, + }); + const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog"); + + // Resolves without throwing "found 2"; the workflow_runs price is added as its own metered + // line item (no quantity), alongside base and responses. + await expect(getCatalogItemsForPlan("scale", "monthly")).resolves.toEqual([ + { price: "price_scale_monthly", quantity: 1 }, + { price: "price_scale_responses" }, + { price: "price_scale_workflow_runs" }, + ]); + }, + TEST_TIMEOUT_MS + ); + + test( + "omits the workflow_runs line item when no such price exists on the plan (getOptionalSinglePrice -> null)", + async () => { + // No workflow_runs price anywhere in the catalog: the optional lookup resolves to null and the + // Scale subscription is provisioned with base + responses only. + mocks.pricesList.mockResolvedValue({ + data: STANDARD_CATALOG_PRICES, + has_more: false, + }); + + const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog"); + + await expect(getCatalogItemsForPlan("scale", "monthly")).resolves.toEqual([ + { price: "price_scale_monthly", quantity: 1 }, + { price: "price_scale_responses" }, + ]); + }, + TEST_TIMEOUT_MS + ); + + test( + "rejects catalog construction when a plan has ambiguous (duplicate) workflow_runs prices", + async () => { + // Two active workflow_runs prices for scale/monthly: getOptionalSinglePrice must surface the + // ambiguity rather than silently pick one, so the whole catalog build fails fast. mocks.pricesList.mockResolvedValue({ data: [ - createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }), - createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }), - createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }), - createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }), - createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }), - createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }), - createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }), - workflowRunsPrice, + ...STANDARD_CATALOG_PRICES, + createWorkflowRunsPrice("price_scale_workflow_runs_a"), + createWorkflowRunsPrice("price_scale_workflow_runs_b"), ], has_more: false, }); const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog"); - // Resolves without throwing "found 2", and the workflow_runs price is not part of the catalog. + await expect(getCatalogItemsForPlan("scale", "monthly")).rejects.toThrow( + "Expected at most one Stripe price for scale/workflow_runs/monthly, but found 2" + ); + }, + TEST_TIMEOUT_MS + ); + + test( + "exposes the included workflow-run volume from the price's free first tier (single source of truth)", + async () => { + // createWorkflowRunsPrice uses RESPONSE_PRICE_TIERS whose first tier is free (unit_amount 0) up + // to 2000, so the displayed included volume must be 2000 — derived from the price, not an + // entitlement — and null on plans with no workflow price. + mocks.pricesList.mockResolvedValue({ + data: [...STANDARD_CATALOG_PRICES, createWorkflowRunsPrice("price_scale_workflow_runs")], + has_more: false, + }); + + const { getStripeBillingCatalogDisplay } = await import("./stripe-billing-catalog"); + const display = await getStripeBillingCatalogDisplay(); + + expect(display.scale.monthly.workflowRunsIncluded).toBe(2000); + expect(display.scale.yearly.workflowRunsIncluded).toBe(2000); + expect(display.pro.monthly.workflowRunsIncluded).toBeNull(); + expect(display.hobby.monthly.workflowRunsIncluded).toBeNull(); + // The graduated tiers are exposed for the plan-card tooltip, straight from the price. + expect(display.scale.monthly.workflowRunsOverage).toEqual(EXPECTED_RESPONSE_OVERAGE); + expect(display.pro.monthly.workflowRunsOverage).toBeNull(); + }, + TEST_TIMEOUT_MS + ); + + test( + "degrades the workflow item only (keeps the catalog up) when the workflow price has no free first tier", + async () => { + // The footgun: a metered workflow price that charges from run #1 (first tier is NOT free). The + // usage card must not claim "X of N included" while Stripe invoices every run. Rather than take + // down the whole catalog (checkout + billing for every plan), the workflow item is dropped + // (price + included → null, card hides, new subs skip it) and the failure is logged loudly; the + // rest of the catalog resolves normally (ENG-2193/2194). + const paidFromFirstUnit = { + ...createWorkflowRunsPrice("price_scale_workflow_runs"), + tiers: [ + { + flat_amount: null, + flat_amount_decimal: null, + unit_amount: 8, + unit_amount_decimal: "8", + up_to: 2000, + }, + { + flat_amount: null, + flat_amount_decimal: null, + unit_amount: 2, + unit_amount_decimal: "2", + up_to: null, + }, + ], + }; + + mocks.pricesList.mockResolvedValue({ + data: [...STANDARD_CATALOG_PRICES, paidFromFirstUnit], + has_more: false, + }); + + const { getStripeBillingCatalogDisplay, getCatalogItemsForPlan } = + await import("./stripe-billing-catalog"); + + // Catalog resolves — no throw — and the workflow item is degraded to null (no false allowance). + const display = await getStripeBillingCatalogDisplay(); + expect(display.scale.monthly.workflowRunsIncluded).toBeNull(); + expect(display.scale.monthly.workflowRunsOverage).toBeNull(); + + // New Scale checkout skips the misconfigured workflow price (base + responses only). await expect(getCatalogItemsForPlan("scale", "monthly")).resolves.toEqual([ { price: "price_scale_monthly", quantity: 1 }, { price: "price_scale_responses" }, ]); + + expect(mocks.loggerError).toHaveBeenCalled(); }, TEST_TIMEOUT_MS ); diff --git a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts index 23912d516194..5ba4969d7cbf 100644 --- a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts +++ b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts @@ -2,6 +2,7 @@ import "server-only"; import { cache as reactCache } from "react"; import Stripe from "stripe"; import { createCacheKey } from "@formbricks/cache"; +import { logger } from "@formbricks/logger"; import type { TCloudBillingInterval } from "@formbricks/types/organizations"; import { cache } from "@/lib/cache"; import { env } from "@/lib/env"; @@ -10,7 +11,7 @@ import { type TResponsePricingTier, mapStripeTiersToResponsePricingTiers } from import { stripeClient } from "./stripe-client"; export type TStandardCloudPlan = "hobby" | "pro" | "scale"; -type TStripePriceKind = "base" | "responses"; +type TStripePriceKind = "base" | "responses" | "workflow_runs"; type TStripeCatalogPrice = Stripe.Price & { product: Stripe.Product | Stripe.DeletedProduct; @@ -21,6 +22,16 @@ export type TStripeBillingCatalogItem = { interval: TCloudBillingInterval; basePrice: TStripeCatalogPrice; responsePrice: TStripeCatalogPrice | null; + // Metered workflow-run overage. Only present on plans that include workflows (Scale today); null + // elsewhere. Like responsePrice it is always the monthly-billed metered variant, even for a yearly + // base, because usage is aggregated and billed monthly (ENG-1936). + workflowRunsPrice: TStripeCatalogPrice | null; + // The included (free) workflow-run allowance DERIVED FROM the price's own free first tier — the + // single source of truth the usage card renders. Null when there is no workflow price. This is + // deliberately NOT the entitlement's included- value: the number the UI shows and the number + // Stripe leaves uncharged must be the same object, or the card can reassure a customer they are + // inside an allowance while Stripe invoices them (ENG-2193/2194). + workflowRunsIncluded: number | null; }; export type TStripeBillingCatalog = { @@ -48,6 +59,11 @@ export type TStripeBillingCatalogDisplayItem = { currency: string; unitAmount: number | null; responseOverage: TResponseOverageDisplay | null; + // Free workflow-run allowance derived from the price's free first tier (see TStripeBillingCatalogItem). + workflowRunsIncluded: number | null; + // Graduated overage tiers for workflow runs, derived from the price (same shape/source as + // responseOverage) so the plan card can show the per-unit tier table straight from Stripe. + workflowRunsOverage: TResponseOverageDisplay | null; }; export type TStripeBillingCatalogDisplay = { @@ -66,8 +82,8 @@ export type TStripeBillingCatalogDisplay = { const STANDARD_CLOUD_PLANS = new Set(["hobby", "pro", "scale"]); const STRIPE_BILLING_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000; -// v2: response prices include expanded tiers -const STRIPE_BILLING_CATALOG_CACHE_VERSION = "v2"; +// v3: catalog item carries workflowRunsPrice (metered workflow overage) +const STRIPE_BILLING_CATALOG_CACHE_VERSION = "v3"; const getStripeBillingCatalogCacheKey = () => createCacheKey.custom( @@ -114,14 +130,13 @@ const getPriceInterval = (price: Stripe.Price): TCloudBillingInterval | null => const getPriceKind = (price: Stripe.Price): TStripePriceKind | null => { const metadataKind = price.metadata?.formbricks_price_kind; - if (metadataKind === "base" || metadataKind === "responses") { + if (metadataKind === "base" || metadataKind === "responses" || metadataKind === "workflow_runs") { return metadataKind; } - // A metered price explicitly tagged with a different kind (e.g. "workflow_runs") is a separate - // metered product, not part of the base/responses plan catalog. Exclude it here so the usage_type - // fallback below can't misclassify it as "responses" and collide with the real responses price on - // the same plan/interval (ENG-1936). Full catalog wiring for such kinds is added separately. + // A metered price tagged with an unrecognized kind is a separate metered product, not part of the + // plan catalog. Exclude it here so the usage_type fallback below can't misclassify it as + // "responses" and collide with the real responses price on the same plan/interval (ENG-1936). if (metadataKind) { return null; } @@ -199,6 +214,58 @@ const getSinglePrice = ( return matches[0]; }; +// Like getSinglePrice, but tolerates absence: returns null when no price matches (the kind is not +// offered on this plan), still throwing on ambiguity (>1) so a duplicate can never be silently +// ignored. Used for optional kinds like workflow_runs that exist only on some plans. +const getOptionalSinglePrice = ( + prices: TStripeCatalogPrice[], + plan: TStandardCloudPlan, + kind: TStripePriceKind, + interval: TCloudBillingInterval +): TStripeCatalogPrice | null => { + const matches = prices.filter( + (price) => + getPricePlan(price) === plan && getPriceKind(price) === kind && getPriceInterval(price) === interval + ); + + if (matches.length > 1) { + throw new Error( + `Expected at most one Stripe price for ${plan}/${kind}/${interval}, but found ${matches.length}` + ); + } + + return matches[0] ?? null; +}; + +// Resolve the included (free) workflow-run allowance from the price's OWN tier structure, and fail +// closed if the price can't honor an "included volume" claim. A graduated price whose first tier is +// free (unit_amount 0, flat_amount 0) up to a finite boundary grants exactly that many free runs — +// the number the usage card shows. Any other shape (flat per-unit, a paid first tier, or an +// unbounded free tier) means the card's "X of N included" would not match what Stripe charges, so we +// throw rather than let a reassuring-but-false allowance render (ENG-2193/2194). The caller catches +// this and degrades the workflow item only, keeping the rest of the catalog up. Returns null when the +// plan has no workflow price at all. +const resolveWorkflowIncludedVolume = (price: TStripeCatalogPrice | null, label: string): number | null => { + if (!price) { + return null; + } + + const firstTier = price.tiers?.[0]; + const firstTierIsFree = + firstTier != null && (firstTier.unit_amount ?? 0) === 0 && (firstTier.flat_amount ?? 0) === 0; + + if (price.tiers_mode !== "graduated" || !firstTierIsFree || typeof firstTier?.up_to !== "number") { + throw new Error( + `Metered workflow price ${label} (${price.id}) must be graduated with a free first tier ` + + `(unit_amount 0, finite up_to) so the included volume shown to customers matches what Stripe ` + + `leaves uncharged; got tiers_mode=${price.tiers_mode ?? "null"}, ` + + `firstTier=${JSON.stringify(firstTier ?? null)}` + ); + } + + return firstTier.up_to; +}; + const fetchStripeBillingCatalog = async (): Promise => { if (!stripeClient) { throw new Error("Stripe is not configured"); @@ -210,6 +277,36 @@ const fetchStripeBillingCatalog = async (): Promise => { throw new Error("No active Stripe billing catalog prices found"); } + // Resolve the workflow price AND its validated included volume together, so both the price object + // and the number the UI trusts come from the same source and can't drift. + // + // Fail closed on the WORKFLOW ITEM ONLY, not the whole catalog: a misconfigured workflow price + // (see resolveWorkflowIncludedVolume) drops the workflow price + included volume to null and logs + // loudly, instead of throwing. The card then hides and new checkouts skip the workflow line item — + // i.e. workflows simply don't bill, today's harmless state — while base/responses checkout and the + // billing page for every plan/org stay up. Base/responses themselves stay fail-loud (getSinglePrice + // throws): they are load-bearing on every plan, so a missing one is a real catalog outage. + const workflowRuns = ( + plan: TStandardCloudPlan + ): Pick => { + const workflowRunsPrice = getOptionalSinglePrice(prices, plan, "workflow_runs", "monthly"); + try { + return { + workflowRunsPrice, + workflowRunsIncluded: resolveWorkflowIncludedVolume( + workflowRunsPrice, + `${plan}/workflow_runs/monthly` + ), + }; + } catch (error) { + logger.error( + { error, plan, priceId: workflowRunsPrice?.id }, + "Invalid workflow_runs price; excluding it from the catalog so the rest of billing stays up" + ); + return { workflowRunsPrice: null, workflowRunsIncluded: null }; + } + }; + return { hobby: { monthly: { @@ -217,6 +314,7 @@ const fetchStripeBillingCatalog = async (): Promise => { interval: "monthly", basePrice: getSinglePrice(prices, "hobby", "base", "monthly"), responsePrice: null, + ...workflowRuns("hobby"), }, }, pro: { @@ -225,12 +323,14 @@ const fetchStripeBillingCatalog = async (): Promise => { interval: "monthly", basePrice: getSinglePrice(prices, "pro", "base", "monthly"), responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"), + ...workflowRuns("pro"), }, yearly: { plan: "pro", interval: "yearly", basePrice: getSinglePrice(prices, "pro", "base", "yearly"), responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"), + ...workflowRuns("pro"), }, }, scale: { @@ -239,12 +339,14 @@ const fetchStripeBillingCatalog = async (): Promise => { interval: "monthly", basePrice: getSinglePrice(prices, "scale", "base", "monthly"), responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"), + ...workflowRuns("scale"), }, yearly: { plan: "scale", interval: "yearly", basePrice: getSinglePrice(prices, "scale", "base", "yearly"), responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"), + ...workflowRuns("scale"), }, }, }; @@ -258,19 +360,21 @@ export const getStripeBillingCatalog = reactCache(async (): Promise { - // The tier table renders graduated semantics (each band priced separately), - // so volume-mode prices must not be displayed with it. - if (item.responsePrice?.tiers_mode !== "graduated") { +// Derive the per-unit overage tier table shown in the plan card straight from a graduated Stripe +// price, so the displayed pricing can never drift from what Stripe charges. Used for both responses +// and workflow-run metered prices. The tier table renders graduated semantics (each band priced +// separately), so volume-mode prices must not be displayed with it. +const toOverageDisplay = (price: TStripeCatalogPrice | null): TResponseOverageDisplay | null => { + if (price?.tiers_mode !== "graduated") { return null; } - const tiers = mapStripeTiersToResponsePricingTiers(item.responsePrice.tiers); + const tiers = mapStripeTiersToResponsePricingTiers(price.tiers); if (!tiers) { return null; } - return { currency: item.responsePrice.currency, tiers }; + return { currency: price.currency, tiers }; }; const toDisplayItem = (item: TStripeBillingCatalogItem): TStripeBillingCatalogDisplayItem => ({ @@ -278,7 +382,9 @@ const toDisplayItem = (item: TStripeBillingCatalogItem): TStripeBillingCatalogDi interval: item.interval, currency: item.basePrice.currency, unitAmount: item.basePrice.unit_amount, - responseOverage: toResponseOverageDisplay(item), + responseOverage: toOverageDisplay(item.responsePrice), + workflowRunsIncluded: item.workflowRunsIncluded, + workflowRunsOverage: toOverageDisplay(item.workflowRunsPrice), }); export const getStripeBillingCatalogDisplay = reactCache(async (): Promise => { @@ -321,6 +427,8 @@ export const getCatalogItemsForPlan = async ( return [ { price: item.basePrice.id, quantity: 1 }, ...(item.responsePrice ? [{ price: item.responsePrice.id }] : []), + // Metered items carry no quantity (usage is reported via meter events). + ...(item.workflowRunsPrice ? [{ price: item.workflowRunsPrice.id }] : []), ]; }; diff --git a/apps/web/modules/ee/billing/page.tsx b/apps/web/modules/ee/billing/page.tsx index 1e4a37300c92..ec98b0379377 100644 --- a/apps/web/modules/ee/billing/page.tsx +++ b/apps/web/modules/ee/billing/page.tsx @@ -1,7 +1,10 @@ import { notFound } from "next/navigation"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { env } from "@/lib/env"; -import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; +import { + getMonthlyOrganizationResponseCount, + getMonthlyOrganizationWorkflowRunCount, +} from "@/lib/organization/service"; import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag"; import { getOrganizationWorkspacesCount } from "@/lib/workspace/service"; import { getTranslate } from "@/lingodotdev/server"; @@ -32,9 +35,10 @@ export const PricingPage = async (props: { params: Promise<{ organizationId: str billing: cloudBillingDisplayContext.billing, }; - const [responseCount, workspaceCount, planComparisonFlag] = await Promise.all([ + const [responseCount, workspaceCount, workflowRunCount, planComparisonFlag] = await Promise.all([ getMonthlyOrganizationResponseCount(organization.id), getOrganizationWorkspacesCount(organization.id), + getMonthlyOrganizationWorkflowRunCount(organization.id), getPostHogFeatureFlag(session.user.id, "a-b_billing_plan-comparison-table"), ]); @@ -48,6 +52,7 @@ export const PricingPage = async (props: { params: Promise<{ organizationId: str organization={organizationWithSyncedBilling} responseCount={responseCount} workspaceCount={workspaceCount} + workflowRunCount={workflowRunCount} isPlanComparison={planComparisonFlag === "test"} hasBillingRights={hasBillingRights} currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan} diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx index 0919b253512f..b125878475f5 100644 --- a/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx @@ -9,7 +9,10 @@ import { WorkflowFieldError, WorkflowFieldLabel, } from "@/modules/ee/workflows/components/inspector/workflow-field"; -import type { EmailSendToOption } from "@/modules/survey/follow-ups/lib/email-send-to-options"; +import { + type EmailSendToOption, + findEmailSendToOption, +} from "@/modules/survey/follow-ups/lib/email-send-to-options"; import { getElementIconMap } from "@/modules/survey/lib/elements"; import { Select, @@ -21,6 +24,7 @@ import { const FIELD_ID = "workflow-email-to"; const ERROR_ID = "workflow-email-to-error"; +const UNAVAILABLE_ID = "workflow-email-to-unavailable"; type TElementIconMap = ReturnType; @@ -63,9 +67,10 @@ interface WorkflowEmailRecipientFieldProps { /** * The `send_email` recipient picker: a grouped Select over the bound survey's email-bearing - * elements, hidden fields and the team roster. Split out of `WorkflowEmailActionForm` because it is - * the one field there with real internal structure (option grouping, per-type icons, a no-options - * fallback), and it owns the only icon map and item renderer in that form. + * elements, hidden fields and the members who can access the workspace. Split out of + * `WorkflowEmailActionForm` because it is the one field there with real internal structure (option + * grouping, per-type icons, a no-options fallback), and it owns the only icon map and item renderer + * in that form. */ export const WorkflowEmailRecipientField = ({ options, @@ -92,6 +97,24 @@ export const WorkflowEmailRecipientField = ({ { label: t("common.members"), options: optionsOfType("user") }, ].filter((group) => group.options.length > 0); + // A stored `to` that matches no option would otherwise render as the placeholder — Radix falls back + // to it when nothing is selected — so the author sees an empty field while the definition still + // holds (and the runner still tries) that recipient. Render it as its own flagged entry instead. + // It covers both causes: a member who lost access to this workspace (ENG-2186) and an element or + // hidden-field id left dangling by a survey edit. Matching goes through the same email + // normalization the runtime allowlist uses, so a `to` that only differs in case from a member's + // address is not flagged as unavailable while it still sends. + const matchedOption = findEmailSendToOption(options, value); + const isValueUnavailable = value !== "" && !matchedOption; + + // Radix selects by exact string match, so drive it with the resolved option's id — otherwise a + // case-differing stored `to` would render as the placeholder, i.e. an empty field. + const selectedValue = matchedOption?.id ?? (value || undefined); + + const describedBy = [isInvalid ? ERROR_ID : null, isValueUnavailable ? UNAVAILABLE_ID : null] + .filter(Boolean) + .join(" "); + return (
@@ -100,9 +123,9 @@ export const WorkflowEmailRecipientField = ({

{t("workspace.surveys.edit.follow_ups_modal_action_to_description")}

- {groups.length > 0 ? ( + {groups.length > 0 || isValueUnavailable ? ( ) : ( -
-