type JsonSchema = { [key: string]: unknown; type?: string | string[]; properties?: Record; items?: JsonSchema | JsonSchema[]; anyOf?: JsonSchema[]; oneOf?: JsonSchema[]; $ref?: string; definitions?: Record; }; export type WorkflowDesignerCatalogKind = 'core-object' | 'transform' | 'app' | 'ai'; export type WorkflowDesignerCatalogAction = { id: string; version: number; label: string; description?: string; inputFieldNames: string[]; outputFieldNames: string[]; }; export type WorkflowDesignerCatalogRecord = { groupKey: string; label: string; iconToken: string; tileKind: WorkflowDesignerCatalogKind; allowedActionIds: string[]; defaultActionId?: string; description?: string; actions: WorkflowDesignerCatalogAction[]; /** * False when the record's integration is not connected for the tenant. * Availability gates ADDING (palette) — existing steps still render from * the record so the editor can explain why they will fail at run time. */ available?: boolean; }; export type WorkflowDesignerCatalogSourceAction = { id: string; version: number; ui?: { label?: string; description?: string; category?: string; icon?: string; }; inputSchema?: JsonSchema; outputSchema?: JsonSchema; }; export type WorkflowDesignerCatalogModuleDefinition = { groupKey: `app:${string}`; label: string; description?: string; tileKind: Extract; iconToken: string; defaultActionId?: string; allowedActionIds: string[]; availabilityKey?: string; }; type BuiltInCatalogSeed = { groupKey: string; label: string; iconToken: string; tileKind: Extract; description: string; defaultActionId?: string; modules?: string[]; actionIds?: string[]; }; const BUILT_IN_CATALOG_SEEDS: BuiltInCatalogSeed[] = [ { groupKey: 'ticket', label: 'Ticket', iconToken: 'ticket', tileKind: 'core-object', description: 'Create, find, update, assign, and manage tickets.', defaultActionId: 'tickets.create', modules: ['tickets'], actionIds: ['assets.find_associated_tickets'] }, { groupKey: 'contact', label: 'Contact', iconToken: 'contact', tileKind: 'core-object', description: 'Find and search contacts for downstream workflow steps.', defaultActionId: 'contacts.find', modules: ['contacts'] }, { groupKey: 'client', label: 'Client', iconToken: 'client', tileKind: 'core-object', description: 'Find and search clients from the PSA client directory.', defaultActionId: 'clients.find', modules: ['clients'] }, { groupKey: 'communication', label: 'Communication', iconToken: 'communication', tileKind: 'core-object', description: 'Send customer-facing or internal communications.', defaultActionId: 'email.send', modules: ['email', 'notifications'] }, { groupKey: 'scheduling', label: 'Scheduling', iconToken: 'scheduling', tileKind: 'core-object', description: 'Assign and update scheduled work.', defaultActionId: 'scheduling.assign_user', modules: ['scheduling'] }, { groupKey: 'project', label: 'Project', iconToken: 'project', tileKind: 'core-object', description: 'Create and manage project work items.', defaultActionId: 'projects.create_task', modules: ['projects'] }, { groupKey: 'time', label: 'Time', iconToken: 'time', tileKind: 'core-object', description: 'Create and manage time tracking entries.', defaultActionId: 'time.create_entry', modules: ['time'] }, { groupKey: 'crm', label: 'CRM', iconToken: 'crm', tileKind: 'core-object', description: 'Create and track CRM activity records.', defaultActionId: 'crm.create_activity_note', modules: ['crm'] }, { groupKey: 'data-store', label: 'Data Store', iconToken: 'data-store', tileKind: 'core-object', description: 'Read and write durable workflow state across runs.', defaultActionId: 'store.get', modules: ['store', 'links'] }, { groupKey: 'transform', label: 'Transform', iconToken: 'transform', tileKind: 'transform', description: 'Shape and normalize workflow data without raw expressions.', modules: ['transform'] }, { groupKey: 'ai', label: 'AI', iconToken: 'ai', tileKind: 'ai', description: 'Infer structured workflow data with the configured AI provider.', defaultActionId: 'ai.infer', actionIds: ['ai.infer'] } ]; const BUILT_IN_MODULE_TO_GROUP = new Map(); for (const seed of BUILT_IN_CATALOG_SEEDS) { for (const moduleName of seed.modules ?? []) { BUILT_IN_MODULE_TO_GROUP.set(moduleName, seed); } } const getActionModuleName = (actionId: string): string => actionId.split('.')[0]?.trim().toLowerCase() ?? ''; const toTitleCase = (value: string): string => value .split(/[\s._-]+/) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' '); const resolveSchema = (schema: JsonSchema | undefined, root?: JsonSchema): JsonSchema | undefined => { if (!schema) return undefined; if (schema.$ref && root?.definitions) { const refKey = schema.$ref.replace('#/definitions/', ''); const resolved = root.definitions?.[refKey]; if (resolved) return resolveSchema(resolved, root); } if (schema.anyOf?.length) { const nonNullVariant = schema.anyOf.find((variant) => { if (!variant?.type) return true; if (Array.isArray(variant.type)) return variant.type.some((entry) => entry !== 'null'); return variant.type !== 'null'; }); if (nonNullVariant) return resolveSchema(nonNullVariant, root); } if (schema.oneOf?.length) { const firstVariant = schema.oneOf[0]; if (firstVariant) return resolveSchema(firstVariant, root); } return schema; }; const extractTopLevelFieldNames = (schema: JsonSchema | undefined): string[] => { const resolved = resolveSchema(schema, schema); if (!resolved?.properties) return []; return Object.keys(resolved.properties).sort((left, right) => left.localeCompare(right)); }; const toCatalogAction = (action: WorkflowDesignerCatalogSourceAction): WorkflowDesignerCatalogAction => ({ id: action.id, version: action.version, label: action.ui?.label?.trim() || action.id, description: action.ui?.description?.trim() || undefined, inputFieldNames: extractTopLevelFieldNames(action.inputSchema), outputFieldNames: extractTopLevelFieldNames(action.outputSchema) }); export const buildWorkflowDesignerActionCatalog = ( actions: WorkflowDesignerCatalogSourceAction[], options?: { modules?: WorkflowDesignerCatalogModuleDefinition[] } ): WorkflowDesignerCatalogRecord[] => { const catalogActions = actions .map((action) => ({ source: action, moduleName: getActionModuleName(action.id), catalogAction: toCatalogAction(action) })); const records: WorkflowDesignerCatalogRecord[] = BUILT_IN_CATALOG_SEEDS.map((seed) => { const matchingActions = catalogActions .filter(({ source, moduleName }) => { if (seed.actionIds?.includes(source.id)) return true; if (seed.modules?.includes(moduleName)) return true; return false; }) .map(({ catalogAction }) => catalogAction) .sort((left, right) => left.label.localeCompare(right.label)); return { groupKey: seed.groupKey, label: seed.label, iconToken: seed.iconToken, tileKind: seed.tileKind, allowedActionIds: matchingActions.map((action) => action.id), defaultActionId: seed.defaultActionId, description: seed.description, actions: matchingActions }; }); const builtInActionIds = new Set(records.flatMap((record) => record.allowedActionIds)); const configuredModules = (options?.modules ?? []).map((module) => ({ ...module, allowedActionIds: [...new Set(module.allowedActionIds)] })); const configuredModuleActionIds = new Set(configuredModules.flatMap((module) => module.allowedActionIds)); const appRecords = new Map(); for (const module of configuredModules) { const actionMap = new Map(catalogActions.map((entry) => [entry.source.id, entry.catalogAction])); const matchingActions = module.allowedActionIds .map((actionId) => actionMap.get(actionId)) .filter((value): value is WorkflowDesignerCatalogAction => Boolean(value)) .sort((left, right) => left.label.localeCompare(right.label)); appRecords.set(module.groupKey, { groupKey: module.groupKey, label: module.label, iconToken: module.iconToken, tileKind: module.tileKind, allowedActionIds: matchingActions.map((action) => action.id), defaultActionId: module.defaultActionId, description: module.description, actions: matchingActions }); } for (const { source, moduleName, catalogAction } of catalogActions) { if (builtInActionIds.has(source.id)) continue; if (configuredModuleActionIds.has(source.id)) continue; const normalizedModuleName = moduleName || source.id.trim().toLowerCase(); if (!normalizedModuleName) continue; const groupKey = `app:${normalizedModuleName}`; const existing = appRecords.get(groupKey); if (existing) { existing.allowedActionIds.push(source.id); existing.actions.push(catalogAction); continue; } const label = toTitleCase(normalizedModuleName); appRecords.set(groupKey, { groupKey, label, iconToken: source.ui?.icon?.trim() || 'app', tileKind: 'app', allowedActionIds: [source.id], description: `App actions exposed by ${label}.`, actions: [catalogAction] }); } const sortedAppRecords = Array.from(appRecords.values()) .map((record) => ({ ...record, allowedActionIds: [...record.allowedActionIds].sort((left, right) => left.localeCompare(right)), actions: [...record.actions].sort((left, right) => left.label.localeCompare(right.label)) })) .sort((left, right) => left.label.localeCompare(right.label)); return [...records, ...sortedAppRecords]; }; export const getWorkflowDesignerCatalogRecordForAction = ( catalog: WorkflowDesignerCatalogRecord[], actionId: string | null | undefined ): WorkflowDesignerCatalogRecord | undefined => { if (!actionId) return undefined; return catalog.find((record) => record.allowedActionIds.includes(actionId)); }; export const isBuiltInWorkflowDesignerGroup = (groupKey: string): boolean => BUILT_IN_CATALOG_SEEDS.some((seed) => seed.groupKey === groupKey); export const getBuiltInWorkflowDesignerCatalogSeed = (moduleName: string): BuiltInCatalogSeed | undefined => BUILT_IN_MODULE_TO_GROUP.get(moduleName.trim().toLowerCase());