diff --git a/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts new file mode 100644 index 00000000000..11c241986e1 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations' +import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' + +export type ManageContext = NonNullable>> + +/** + * Confirm the caller can manage (edit/delete) the block: admin of the block's + * SOURCE workflow's workspace — matching who could publish it. Org admins/owners + * hold admin on every org workspace, so they pass too; a workspace admin from a + * different workspace does not, so they cannot alter another workspace's block or + * its exposed outputs. + */ +export async function authorizeManage( + userId: string, + id: string +): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> { + const ctx = await getCustomBlockManageContext(id) + if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null } + + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { + return { + error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), + ctx: null, + } + } + if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { + return { + error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }), + ctx: null, + } + } + return { error: null, ctx } +} diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts index 14d7d06ee9f..474d8ea273f 100644 --- a/apps/sim/app/api/custom-blocks/[id]/route.ts +++ b/apps/sim/app/api/custom-blocks/[id]/route.ts @@ -9,51 +9,19 @@ import { } from '@/lib/api/contracts/custom-blocks' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CustomBlockValidationError, deleteCustomBlock, - getCustomBlockManageContext, + getCustomBlockUsageCounts, updateCustomBlock, } from '@/lib/workflows/custom-blocks/operations' -import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage' const logger = createLogger('CustomBlockAPI') type RouteContext = { params: Promise<{ id: string }> } -/** - * Confirm the caller can manage (edit/delete) the block: admin of the block's - * SOURCE workflow's workspace — matching who could publish it. Org admins/owners - * hold admin on every org workspace, so they pass too; a workspace admin from a - * different workspace does not, so they cannot alter another workspace's block or - * its exposed outputs. - */ -type ManageContext = NonNullable>> - -async function authorizeManage( - userId: string, - id: string -): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> { - const ctx = await getCustomBlockManageContext(id) - if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null } - - if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { - return { - error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), - ctx: null, - } - } - if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { - return { - error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }), - ctx: null, - } - } - return { error: null, ctx } -} - export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { const session = await getSession() if (!session?.user?.id) { @@ -115,6 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou if (authz.error) return authz.error const { ctx } = authz + const usageCounts = await getCustomBlockUsageCounts(ctx.organizationId, ctx.type) await deleteCustomBlock(id) recordAudit({ workspaceId: ctx.sourceWorkspaceId, @@ -126,7 +95,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou resourceId: id, resourceName: ctx.name, description: `Unpublished custom block "${ctx.name}"`, - metadata: { organizationId: ctx.organizationId, type: ctx.type }, + metadata: { + organizationId: ctx.organizationId, + type: ctx.type, + usageCount: usageCounts.usageCount, + deployedUsageCount: usageCounts.deployedUsageCount, + }, request, }) return NextResponse.json({ success: true as const }) diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts new file mode 100644 index 00000000000..836f7960969 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockHasWorkspaceAdminAccess: vi.fn(), + mockOperations: { + getCustomBlockManageContext: vi.fn(), + getCustomBlockUsageCounts: vi.fn(), + }, + })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations) + +import { GET } from '@/app/api/custom-blocks/[id]/usages/route' + +const MANAGE_CONTEXT = { + organizationId: 'org-1', + sourceWorkspaceId: 'ws-1', + type: 'custom_block_abc123', + name: 'Invoice Parser', +} + +const USAGE_COUNTS = { usageCount: 3, deployedUsageCount: 2 } + +function callRoute(id = 'cb-1') { + return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) }) +} + +describe('GET /api/custom-blocks/[id]/usages', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockIsFeatureEnabled.mockResolvedValue(true) + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT) + mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS) + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(401) + }) + + it('returns 404 for an unknown block', async () => { + mockOperations.getCustomBlockManageContext.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(404) + }) + + it('returns 403 when the feature flag is off', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + const response = await callRoute() + expect(response.status).toBe(403) + }) + + it('returns 403 for a non-admin of the source workspace', async () => { + mockHasWorkspaceAdminAccess.mockResolvedValue(false) + const response = await callRoute() + expect(response.status).toBe(403) + expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled() + }) + + it('returns the org-scoped usage counts for the block type', async () => { + const response = await callRoute() + expect(response.status).toBe(200) + expect(await response.json()).toEqual(USAGE_COUNTS) + expect(mockOperations.getCustomBlockUsageCounts).toHaveBeenCalledWith( + 'org-1', + 'custom_block_abc123' + ) + }) +}) diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts new file mode 100644 index 00000000000..eee59b2c20a --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getCustomBlockUsageCountsContract } from '@/lib/api/contracts/custom-blocks' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations' +import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage' + +type RouteContext = { params: Promise<{ id: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getCustomBlockUsageCountsContract, request, context) + if (!parsed.success) return parsed.response + + const authz = await authorizeManage(session.user.id, parsed.data.params.id) + if (authz.error) return authz.error + + const counts = await getCustomBlockUsageCounts(authz.ctx.organizationId, authz.ctx.type) + return NextResponse.json(counts) +}) diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts index d13139aaba7..62cc473e7c9 100644 --- a/apps/sim/blocks/custom/build-config.ts +++ b/apps/sim/blocks/custom/build-config.ts @@ -33,6 +33,7 @@ export interface CustomBlockInput { type: string placeholder?: string description?: string + required?: boolean } /** @@ -108,6 +109,9 @@ export function buildCustomBlockConfig( type, description: field.description, placeholder: field.placeholder, + // Serializer Loop-B (required subBlocks not covered by tool params) and the + // editor asterisk both read this — same enforcement path as regular blocks. + required: field.required === true, } if (field.type === 'object' || field.type === 'array') sub.language = 'json' if (field.type === 'file[]') sub.multiple = true diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index d9d48a99eed..7eb676229d2 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -7,6 +7,7 @@ import { ChipCombobox, ChipConfirmModal, ChipInput, + ChipModalField, ChipTextarea, type ComboboxOptionGroup, cn, @@ -14,6 +15,7 @@ import { ExpandableContent, Label, Loader, + Switch, toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' @@ -34,6 +36,7 @@ import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build- import { SettingRow } from '@/ee/components/setting-row' import { useCustomBlocks, + useCustomBlockUsageCounts, useDeleteCustomBlock, usePublishCustomBlock, useUpdateCustomBlock, @@ -138,6 +141,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD const [error, setError] = useState(null) const [showDelete, setShowDelete] = useState(false) + // Type-to-confirm buffer for the delete modal — reset whenever the modal opens. + const [confirmationText, setConfirmationText] = useState('') + const [prevShowDelete, setPrevShowDelete] = useState(false) + if (showDelete !== prevShowDelete) { + setPrevShowDelete(showDelete) + if (showDelete) setConfirmationText('') + } + + // Fetched with the view so the count is warm before the delete modal opens. + // Server-gated on the same manage authz as delete. + const usageCountsQuery = useCustomBlockUsageCounts(existing?.id, { enabled: canManageBlock }) + const usageCount = usageCountsQuery.data?.usageCount ?? 0 + // Edit mode may mount before `useCustomBlocks` has resolved this row, leaving the // buffers empty. Reseed them the first time the block's identity loads (or when it // changes) — keyed on `existing.id` so a later refetch of the SAME block doesn't @@ -177,15 +193,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD return m }, [availableFields]) - const placeholderById = useMemo(() => { - const m = new Map() - for (const i of inputs) m.set(i.id, i.placeholder) + const overrideById = useMemo(() => { + const m = new Map() + for (const i of inputs) m.set(i.id, i) return m }, [inputs]) // Every deployed Start input is exposed (no selection). Name/type/description are - // inherited from the field itself (the Start block already defines them); the only - // thing authored here is the placeholder. + // inherited from the field itself (the Start block already defines them); only + // the placeholder and required flag are authored here. const visibleInputs = useMemo( () => deployedLoaded @@ -196,11 +212,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD name: f.name, type: f.type, description: f.description, - placeholder: placeholderById.get(id), + placeholder: overrideById.get(id)?.placeholder, + required: overrideById.get(id)?.required, } }) : inputs, - [deployedLoaded, availableFields, placeholderById, inputs] + [deployedLoaded, availableFields, overrideById, inputs] ) const [expandedInputs, setExpandedInputs] = useState>(new Set()) @@ -281,15 +298,18 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD deployed.isLoading || (deployedLoaded && visibleOutputs.length === 0) - // Upsert the per-input placeholder (the only authored field). `visibleInputs` + // Upsert an authored per-input override (placeholder/required). `visibleInputs` // shows every deployed field; the first edit of a field adds its override row. - function setPlaceholder(id: string, placeholder: string) { + function setInputOverride( + id: string, + patch: Partial> + ) { setInputs((prev) => { if (prev.some((i) => i.id === id)) { - return prev.map((i) => (i.id === id ? { ...i, placeholder } : i)) + return prev.map((i) => (i.id === id ? { ...i, ...patch } : i)) } const f = fieldById.get(id) - return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', placeholder }] + return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }] }) } @@ -340,11 +360,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD setError('Output names must be unique') return } - // Only the placeholder is authored; the field set/name/type are always derived - // from the deployed Start. Persist just the non-empty placeholder overrides. + // Only the placeholder and required flag are authored; the field set/name/type + // are always derived from the deployed Start. Persist only non-empty overrides. const inputPlaceholders = visibleInputs - .filter((i) => i.placeholder?.trim()) - .map((i) => ({ id: i.id, placeholder: i.placeholder!.trim() })) + .filter((i) => i.placeholder?.trim() || i.required) + .map((i) => ({ + id: i.id, + ...(i.placeholder?.trim() ? { placeholder: i.placeholder.trim() } : {}), + ...(i.required ? { required: true } : {}), + })) try { if (existing) { @@ -408,7 +432,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD { text: remove.isPending ? 'Deleting...' : 'Delete', variant: 'destructive' as const, - onSelect: () => setShowDelete(true), + onSelect: () => { + setShowDelete(true) + // The warning must reflect the org's CURRENT usage, not a + // ≤30s-stale cache entry. + usageCountsQuery.refetch() + }, disabled: remove.isPending, }, ] @@ -604,11 +633,24 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD

)} +
+ + + setInputOverride(i.id, { required: checked }) + } + disabled={!canManageBlock} + /> +
setPlaceholder(i.id, e.target.value)} + onChange={(e) => + setInputOverride(i.id, { placeholder: e.target.value }) + } placeholder='Shown in the empty field' maxLength={200} disabled={!canManageBlock} @@ -686,15 +728,41 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD text={[ 'Delete ', { text: existing?.name ?? 'this block', bold: true }, - '? Workflows already using it will stop resolving it. This cannot be undone.', + '? This cannot be undone.', ]} confirm={{ label: 'Delete', onClick: handleDelete, pending: remove.isPending, pendingLabel: 'Deleting...', + disabled: confirmationText !== (existing?.name ?? ''), }} - /> + > + {usageCountsQuery.data && + (usageCount > 0 ? ( +

+ {usageCount} {usageCount === 1 ? 'workflow is' : 'workflows are'} still using this + block and will fail at run time. +

+ ) : ( +

+ No workflows are currently using it. +

+ ))} + + Type  + {existing?.name} +  to confirm + + } + value={confirmationText} + onChange={setConfirmationText} + placeholder={existing?.name} + /> + | undefined ): CustomBlockInput[] { @@ -723,17 +792,20 @@ function toCustomBlockInputs( type: f.type, placeholder: f.placeholder, description: f.description, + required: f.required, })) } /** - * Compare inputs by only the authored data — the field id and its placeholder. - * name/type/description are derived live from the deployed Start (not stored), so - * comparing them would flag the form dirty when only Start metadata drifted. + * Compare inputs by only the authored data — the field id, placeholder, and + * required flag. name/type/description are derived live from the deployed Start + * (not stored), so comparing them would flag the form dirty when only Start + * metadata drifted. */ function normalizeInputsForCompare(items: ReadonlyArray>) { return items.map((i) => ({ id: i.id ?? i.name ?? '', placeholder: i.placeholder ?? '', + required: i.required ?? false, })) } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index ffda0c65a2b..b650cc1059a 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -2,6 +2,7 @@ import { setupGlobalFetchMock } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { + findMissingRequiredCustomBlockInputs, remapCustomBlockInputKeys, WorkflowBlockHandler, } from '@/executor/handlers/workflow/workflow-handler' @@ -432,3 +433,67 @@ describe('remapCustomBlockInputKeys', () => { ).toEqual({ payload: 'not json' }) }) }) + +describe('findMissingRequiredCustomBlockInputs', () => { + const childBlocks = { + start: { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [ + { id: 'f1', name: 'firstName', type: 'string' }, + { id: 'f2', name: 'payload', type: 'object' }, + { name: 'legacyField', type: 'string' }, + ], + }, + }, + }, + } as Record + + it('flags a required field left empty and reports its display name', () => { + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName']) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([ + 'firstName', + ]) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([ + 'firstName', + ]) + }) + + it('passes when the required field has a value', () => { + expect( + findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' }) + ).toEqual([]) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([]) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual( + [] + ) + }) + + it('ignores a stale required override whose field was removed from the Start', () => { + expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([]) + }) + + it('treats fields without an override as optional', () => { + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual( + [] + ) + expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([]) + }) + + it('keys legacy fields without a stable id by name', () => { + expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([ + 'legacyField', + ]) + expect( + findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' }) + ).toEqual([]) + }) + + it('reports every missing required field at once', () => { + expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([ + 'firstName', + 'payload', + ]) + }) +}) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 70d1e52e089..edde6925eb5 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -46,6 +46,41 @@ function getValueAtPath(source: unknown, path: string): unknown { * name. Legacy fields without an id are keyed by name and pass through unchanged. * Keys that match no current field are dropped. */ +/** + * A consumer left a publisher-required custom block input empty. The message is + * consumer-safe (it names only the block's own input labels), so the catch's + * custom-block sanitizer rethrows it verbatim instead of the generic failure. + */ +export class CustomBlockMissingInputsError extends Error { + constructor(message: string) { + super(message) + this.name = 'CustomBlockMissingInputsError' + } +} + +/** + * Names of publisher-required custom block inputs the consumer left empty, checked + * against the child's LIVE deployed Start fields — a required override whose field + * was removed is inert, and a field added after publish has no override, so schema + * drift can never block a run. `childWorkflowInput` is the post-remap mapping + * (keyed by field name). Same empty semantics as the serializer's required check. + */ +export function findMissingRequiredCustomBlockInputs( + requiredInputIds: string[], + childBlocks: Record, + childWorkflowInput: Record +): string[] { + if (requiredInputIds.length === 0) return [] + const requiredIds = new Set(requiredInputIds) + return extractInputFieldsFromBlocks(childBlocks) + .filter((field) => requiredIds.has(field.id ?? field.name)) + .filter((field) => { + const value = childWorkflowInput[field.name] + return value === undefined || value === null || value === '' + }) + .map((field) => field.name) +} + export function remapCustomBlockInputKeys( mapping: Record, childBlocks: Record @@ -162,6 +197,7 @@ export class WorkflowBlockHandler implements BlockHandler { let workflowId = inputs.workflowId let loadUserId = ctx.userId let exposedOutputs: CustomBlockOutput[] = [] + let requiredInputIds: string[] = [] if (isCustomBlock) { const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId) if (!authority) { @@ -170,6 +206,7 @@ export class WorkflowBlockHandler implements BlockHandler { workflowId = authority.workflowId loadUserId = authority.ownerUserId exposedOutputs = authority.exposedOutputs + requiredInputIds = authority.requiredInputIds } if (!workflowId) { @@ -270,6 +307,19 @@ export class WorkflowBlockHandler implements BlockHandler { childWorkflowInput = inputs.input } + if (isCustomBlock) { + const missing = findMissingRequiredCustomBlockInputs( + requiredInputIds, + childWorkflow.rawBlocks || {}, + childWorkflowInput + ) + if (missing.length > 0) { + throw new CustomBlockMissingInputsError( + `${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}` + ) + } + } + const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication( workflowId, childWorkflow.workflowState @@ -406,6 +456,16 @@ export class WorkflowBlockHandler implements BlockHandler { // so capture the child's spans server-side, distill to the aggregate cost, and // carry only that (no internals) so `block-executor` still bills it. if (isCustomBlock) { + // Missing-required-inputs is the consumer's own mistake and its message + // names only the block's input labels — surface it instead of the generic + // failure so they can actually fix it. The child never ran: no spend. + if (error instanceof CustomBlockMissingInputsError) { + throw new ChildWorkflowError({ + message: error.message, + childWorkflowName: block.metadata?.name || 'Custom block', + childWorkflowInstanceId: instanceId, + }) + } let failedChildSpans: WorkflowTraceSpan[] = [] if (hasExecutionResult(error) && error.executionResult.logs) { failedChildSpans = this.captureChildWorkflowLogs( diff --git a/apps/sim/hooks/queries/custom-blocks.ts b/apps/sim/hooks/queries/custom-blocks.ts index 5ab6de8e3b7..55a127fc68f 100644 --- a/apps/sim/hooks/queries/custom-blocks.ts +++ b/apps/sim/hooks/queries/custom-blocks.ts @@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type CustomBlock, + type CustomBlockUsageCounts, deleteCustomBlockContract, + getCustomBlockUsageCountsContract, listCustomBlocksContract, type PublishCustomBlockBody, publishCustomBlockContract, @@ -11,11 +13,14 @@ import { } from '@/lib/api/contracts/custom-blocks' export const CUSTOM_BLOCK_LIST_STALE_TIME = 60 * 1000 +/** Short — the usage count is a pre-delete safety check and must stay fresh. */ +export const CUSTOM_BLOCK_USAGES_STALE_TIME = 30 * 1000 export const customBlockKeys = { all: ['custom-blocks'] as const, lists: () => [...customBlockKeys.all, 'list'] as const, list: (workspaceId?: string) => [...customBlockKeys.lists(), workspaceId ?? ''] as const, + usages: (id?: string) => [...customBlockKeys.all, 'usages', id ?? ''] as const, } interface CustomBlocksResult { @@ -53,6 +58,23 @@ export function useCanPublishCustomBlock(workspaceId?: string) { return useCustomBlocksQuery(workspaceId, (r) => r.enabled) } +function fetchCustomBlockUsageCounts( + id: string, + signal?: AbortSignal +): Promise { + return requestJson(getCustomBlockUsageCountsContract, { params: { id }, signal }) +} + +/** How many workflows across the org place this block (live editor state and/or active deployment). */ +export function useCustomBlockUsageCounts(blockId?: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: customBlockKeys.usages(blockId), + queryFn: ({ signal }) => fetchCustomBlockUsageCounts(blockId as string, signal), + enabled: Boolean(blockId) && (options?.enabled ?? true), + staleTime: CUSTOM_BLOCK_USAGES_STALE_TIME, + }) +} + export function usePublishCustomBlock(workspaceId?: string) { const queryClient = useQueryClient() return useMutation({ diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 95d0915bca2..01adbbddfc0 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -11,17 +11,20 @@ const inputFieldSchema = z.object({ description: z.string().optional(), /** Consumer-facing placeholder hint (curated inputs only). */ placeholder: z.string().optional(), + /** Consumers must fill this input (curated inputs only). */ + required: z.boolean().optional(), }) /** - * The only authored per-input datum: a placeholder, keyed by the source Start - * field's stable `id`. The field's name/type/description are NOT stored — they're - * always derived from the live deployed Start (so they can't go stale), and this - * map only supplies the consumer-facing placeholder hint. + * The authored per-input data: a placeholder and a required flag, keyed by the + * source Start field's stable `id`. The field's name/type/description are NOT + * stored — they're always derived from the live deployed Start (so they can't go + * stale); an override whose field was removed from the Start is silently ignored. */ const inputPlaceholderSchema = z.object({ id: z.string().min(1), placeholder: z.string().max(200).optional(), + required: z.boolean().optional(), }) export type CustomBlockInputPlaceholder = z.input @@ -93,6 +96,18 @@ export const updateCustomBlockBodySchema = z export type UpdateCustomBlockBody = z.input +/** + * How many workflows in the org place this block. Live editor state and the + * active deployment snapshot can diverge, so a workflow counts when the block + * appears in either; `deployedUsageCount` counts active deployments only. + */ +export const customBlockUsageCountsSchema = z.object({ + usageCount: z.number().int().min(0), + deployedUsageCount: z.number().int().min(0), +}) + +export type CustomBlockUsageCounts = z.output + export const listCustomBlocksContract = defineRouteContract({ method: 'GET', path: '/api/custom-blocks', @@ -137,3 +152,13 @@ export const deleteCustomBlockContract = defineRouteContract({ schema: z.object({ success: z.literal(true) }), }, }) + +export const getCustomBlockUsageCountsContract = defineRouteContract({ + method: 'GET', + path: '/api/custom-blocks/[id]/usages', + params: customBlockIdParamsSchema, + response: { + mode: 'json', + schema: customBlockUsageCountsSchema, + }, +}) diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 28d7dc5af35..23e602b83d1 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -1,8 +1,14 @@ import { db } from '@sim/db' -import { customBlock, workflow, workspace } from '@sim/db/schema' +import { + customBlock, + workflow, + workflowBlocks, + workflowDeploymentVersion, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format' @@ -62,15 +68,15 @@ async function deriveInputFields(workflowId: string): Promise [p.id, p.placeholder])) - // Placeholders are stored under `field.id ?? field.name` (the form's key), so a + const byId = new Map(placeholders.map((p) => [p.id, p])) + // Overrides are stored under `field.id ?? field.name` (the form's key), so a // legacy field with no stable id is keyed by name — look it up the same way. return deployed.map((field) => { - const placeholder = byId.get(field.id ?? field.name) - return placeholder ? { ...field, placeholder } : field + const override = byId.get(field.id ?? field.name) + if (!override) return field + return { + ...field, + ...(override.placeholder ? { placeholder: override.placeholder } : {}), + ...(override.required ? { required: true } : {}), + } }) } @@ -250,6 +261,8 @@ export async function getCustomBlockAuthority( organizationId: string ownerUserId: string exposedOutputs: CustomBlockOutput[] + /** Start-field ids (form keys) the publisher marked required. May reference removed fields. */ + requiredInputIds: string[] } | null> { // Scope resolution to the consumer's org: `(organizationId, type)` is the unique // key, so without the org filter a `custom_block_*` type smuggled in from another @@ -267,6 +280,7 @@ export async function getCustomBlockAuthority( organizationId: customBlock.organizationId, enabled: customBlock.enabled, outputs: customBlock.outputs, + inputs: customBlock.inputs, ownerUserId: workflow.userId, }) .from(customBlock) @@ -282,6 +296,7 @@ export async function getCustomBlockAuthority( organizationId: row.organizationId, ownerUserId: row.ownerUserId, exposedOutputs: row.outputs ?? [], + requiredInputIds: (row.inputs ?? []).filter((i) => i.required).map((i) => i.id), } } @@ -432,3 +447,55 @@ export async function updateCustomBlock( export async function deleteCustomBlock(id: string): Promise { await db.delete(customBlock).where(eq(customBlock.id, id)) } + +/** + * How many non-archived workflows in the org place the block, in their live + * editor state and/or their ACTIVE deployment snapshot. The two are scanned + * independently — a block removed in the editor can still ship in the active + * deployment (and vice versa), and the deployed placement is the one that + * actually runs. The deployment scan pre-filters with a raw-text match on the + * unique type slug so only near-exact matches pay the jsonb parse. + */ +export async function getCustomBlockUsageCounts( + organizationId: string, + blockType: string +): Promise<{ usageCount: number; deployedUsageCount: number }> { + const orgActiveWorkflow = and( + eq(workspace.organizationId, organizationId), + isNull(workflow.archivedAt) + ) + // Escape LIKE wildcards — the `_`s in `custom_block_` would otherwise match + // any character and let unrelated states through to the jsonb parse. + const likePattern = `%${blockType.replace(/[\\%_]/g, '\\$&')}%` + + const [liveRows, deployedRows] = await Promise.all([ + db + .selectDistinct({ workflowId: workflow.id }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) + .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .where(and(eq(workflowBlocks.type, blockType), orgActiveWorkflow)), + db + .select({ workflowId: workflow.id }) + .from(workflowDeploymentVersion) + .innerJoin(workflow, eq(workflow.id, workflowDeploymentVersion.workflowId)) + .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .where( + and( + eq(workflowDeploymentVersion.isActive, true), + eq(workflow.isDeployed, true), + orgActiveWorkflow, + sql`${workflowDeploymentVersion.state}::text LIKE ${likePattern} ESCAPE '\\'`, + sql`EXISTS ( + SELECT 1 FROM jsonb_each((${workflowDeploymentVersion.state})::jsonb -> 'blocks') AS b + WHERE b.value ->> 'type' = ${blockType} + )` + ) + ), + ]) + + const usingWorkflowIds = new Set(liveRows.map((r) => r.workflowId)) + for (const row of deployedRows) usingWorkflowIds.add(row.workflowId) + + return { usageCount: usingWorkflowIds.size, deployedUsageCount: deployedRows.length } +} diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 97a63bc7e96..1f73780f65d 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -22,6 +22,11 @@ export interface WorkflowInputField { * in the Custom Blocks settings UI; has no source on the workflow's Start block. */ placeholder?: string + /** + * Consumers must fill this custom-block input. Authored in the Custom Blocks + * settings UI; has no source on the workflow's Start block. + */ + required?: boolean } /** diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 8028d3a3a10..d18762a071e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2893,13 +2893,14 @@ export const customBlock = pgTable( /** Uploaded icon image URL (workspace storage), or null for the default icon. */ iconUrl: text('icon_url'), /** - * Per-input placeholder hints keyed by the source Start field's stable `id`: - * `Array<{ id, placeholder? }>`. Only the placeholder is authored — the input - * field set and its name/type/description are always derived live from the - * deployed Start (so they can never go stale). Absent/empty → no placeholder - * overrides; every deployed Start input is still exposed. + * Per-input authored overrides keyed by the source Start field's stable `id`: + * `Array<{ id, placeholder?, required? }>`. Only the placeholder and required + * flag are authored — the input field set and its name/type/description are + * always derived live from the deployed Start (so they can never go stale); an + * override whose field was removed is ignored. Absent/empty → no overrides; + * every deployed Start input is still exposed. */ - inputs: json('inputs').$type>(), + inputs: json('inputs').$type>(), /** * Curated outputs exposed to consumers: `Array<{ blockId, path, name }>`. Each * maps a child-workflow block output (blockId + dot-path) to a friendly output diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9b54931dd94..d8694b3f6bc 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 926, - zodRoutes: 926, + totalRoutes: 927, + zodRoutes: 927, nonZodRoutes: 0, } as const