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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
344 lines
11 KiB
TypeScript
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());
|