PSA/shared/workflow/runtime/designer/actionCatalog.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

344 lines
11 KiB
TypeScript

type JsonSchema = {
[key: string]: unknown;
type?: string | string[];
properties?: Record<string, JsonSchema>;
items?: JsonSchema | JsonSchema[];
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
$ref?: string;
definitions?: Record<string, JsonSchema>;
};
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<WorkflowDesignerCatalogKind, 'app'>;
iconToken: string;
defaultActionId?: string;
allowedActionIds: string[];
availabilityKey?: string;
};
type BuiltInCatalogSeed = {
groupKey: string;
label: string;
iconToken: string;
tileKind: Extract<WorkflowDesignerCatalogKind, 'core-object' | 'transform' | 'ai'>;
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<string, BuiltInCatalogSeed>();
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<string, WorkflowDesignerCatalogRecord>();
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());