diff --git a/community-nodes/nodes/WorkflowInteractionLayer/WorkflowInteractionLayer.node.ts b/community-nodes/nodes/WorkflowInteractionLayer/WorkflowInteractionLayer.node.ts index 137a63b..07d7763 100644 --- a/community-nodes/nodes/WorkflowInteractionLayer/WorkflowInteractionLayer.node.ts +++ b/community-nodes/nodes/WorkflowInteractionLayer/WorkflowInteractionLayer.node.ts @@ -11,6 +11,16 @@ import { } from 'n8n-workflow'; import { createMessage, listMessages, getMessagesByActor } from './operations/message.operations'; import { createAction, getAction, getActionsByActor, listActions, updateAction } from './operations/action.operations'; +import { + messageCreateProperties, + messageGetByActorProperties, + messageListProperties, + actionCreateProperties, + actionGetProperties, + actionGetByActorProperties, + actionListProperties, + actionUpdateProperties, +} from './shared/properties'; export class WorkflowInteractionLayer implements INodeType { description: INodeTypeDescription = { @@ -79,400 +89,17 @@ export class WorkflowInteractionLayer implements INodeType { default: 'create', }, - // ═══════════════════════════════════════ - // Create Message fields - // ═══════════════════════════════════════ - { - displayName: - 'Workflow ID and Workflow Instance ID are automatically set from the current workflow and execution context', - name: 'autoFieldsNoticeMessage', - type: 'notice', - default: '', - displayOptions: { show: { resource: ['message'], operation: ['create'] } }, - }, - { - displayName: 'Actor ID', - name: 'actorId', - type: 'string', - default: '', - required: true, - description: 'Identifier for the target actor (max 50 characters)', - displayOptions: { show: { resource: ['message'], operation: ['create'] } }, - }, - { - displayName: 'Actor Type', - name: 'actorType', - type: 'options', - default: 'user', - required: true, - options: [ - { name: 'Group', value: 'group' }, - { name: 'Role', value: 'role' }, - { name: 'System', value: 'system' }, - { name: 'User', value: 'user' }, - { name: 'Other', value: 'other' }, - ], - displayOptions: { show: { resource: ['message'], operation: ['create'] } }, - }, - { - displayName: 'Title', - name: 'title', - type: 'string', - default: '', - required: true, - description: 'Message title (max 255 characters)', - displayOptions: { show: { resource: ['message'], operation: ['create'] } }, - }, - { - displayName: 'Body', - name: 'body', - type: 'string', - typeOptions: { rows: 4 }, - default: '', - required: true, - description: 'Message body text', - displayOptions: { show: { resource: ['message'], operation: ['create'] } }, - }, - { - displayName: 'Metadata', - name: 'metadata', - type: 'json', - default: '{}', - description: 'Optional JSON metadata object', - displayOptions: { show: { resource: ['message'], operation: ['create'] } }, - }, - - // ═══════════════════════════════════════ - // Get Messages by Actor ID fields - // ═══════════════════════════════════════ - { - displayName: 'Actor ID', - name: 'actorId', - type: 'string', - default: '', - required: true, - description: 'ID of the actor to retrieve messages for', - displayOptions: { show: { resource: ['message'], operation: ['getByActor'] } }, - }, - { - displayName: 'Since', - name: 'since', - type: 'dateTime', - default: '', - description: 'Filter messages created after this RFC 3339 timestamp', - displayOptions: { show: { resource: ['message'], operation: ['getByActor'] } }, - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - typeOptions: { minValue: 1, maxValue: 200 }, - default: 50, - description: 'Max number of results to return', - displayOptions: { show: { resource: ['message'], operation: ['getByActor'] } }, - }, - { - displayName: 'Workflow Instance ID', - name: 'workflowInstanceId', - type: 'string', - default: '', - description: 'Filter by workflow instance ID', - displayOptions: { show: { resource: ['message'], operation: ['getByActor'] } }, - }, - - // ═══════════════════════════════════════ - // List Messages fields - // ═══════════════════════════════════════ - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - default: false, - description: 'Whether to return all results or only up to a given limit', - displayOptions: { show: { resource: ['message'], operation: ['list'] } }, - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - typeOptions: { minValue: 1, maxValue: 200 }, - default: 50, - description: 'Max number of results to return', - displayOptions: { show: { resource: ['message'], operation: ['list'], returnAll: [false] } }, - }, - { - displayName: 'Actor ID', - name: 'actorId', - type: 'string', - default: '', - description: 'Filter by actor ID', - displayOptions: { show: { resource: ['message'], operation: ['list'] } }, - }, - { - displayName: 'Workflow Instance ID', - name: 'workflowInstanceId', - type: 'string', - default: '', - description: 'Filter by workflow instance ID', - displayOptions: { show: { resource: ['message'], operation: ['list'] } }, - }, - { - displayName: 'Since', - name: 'since', - type: 'dateTime', - default: '', - description: 'Filter messages created after this RFC 3339 timestamp (cursor for pagination)', - displayOptions: { show: { resource: ['message'], operation: ['list'] } }, - }, - - // ═══════════════════════════════════════ - // Create Action fields - // ═══════════════════════════════════════ - { - displayName: - 'Workflow ID and Workflow Instance ID are automatically set from the current workflow and execution context', - name: 'autoFieldsNoticeAction', - type: 'notice', - default: '', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Actor ID', - name: 'actorId', - type: 'string', - default: '', - required: true, - description: 'Identifier for the target actor (max 50 characters)', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Actor Type', - name: 'actorType', - type: 'options', - default: 'user', - required: true, - options: [ - { name: 'Group', value: 'group' }, - { name: 'Role', value: 'role' }, - { name: 'System', value: 'system' }, - { name: 'User', value: 'user' }, - { name: 'Other', value: 'other' }, - ], - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Action Type', - name: 'actionType', - type: 'options', - default: 'getapproval', - required: true, - options: [ - { name: 'Get Approval', value: 'getapproval' }, - { name: 'Show Form', value: 'showform' }, - { name: 'Wait on Event', value: 'waitonevent' }, - ], - description: 'The type of action to create', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Payload', - name: 'payload', - type: 'json', - default: '{}', - required: true, - description: 'For "showform": include formId, formVersion, returnUrl. For "getapproval": free-form JSON.', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Callback URL', - name: 'callbackUrl', - type: 'string', - default: '', - required: true, - description: 'URL to call when the action is completed', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Callback Method', - name: 'callbackMethod', - type: 'options', - default: 'POST', - options: [ - { name: 'POST', value: 'POST' }, - { name: 'PUT', value: 'PUT' }, - { name: 'PATCH', value: 'PATCH' }, - ], - description: 'HTTP method for the callback', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Callback Payload Spec', - name: 'callbackPayloadSpec', - type: 'json', - default: '{}', - description: 'Optional template for expected callback body', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Due Date', - name: 'dueDate', - type: 'dateTime', - default: '', - description: 'Optional due date in RFC 3339 format', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Priority', - name: 'priority', - type: 'options', - default: 'normal', - options: [ - { name: 'Critical', value: 'critical' }, - { name: 'Normal', value: 'normal' }, - ], - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Check In', - name: 'checkIn', - type: 'dateTime', - default: '', - description: 'Optional reminder timestamp in RFC 3339 format', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - { - displayName: 'Metadata', - name: 'metadata', - type: 'json', - default: '{}', - description: 'Optional JSON metadata object', - displayOptions: { show: { resource: ['action'], operation: ['create'] } }, - }, - - // ═══════════════════════════════════════ - // Get Action fields - // ═══════════════════════════════════════ - { - displayName: 'Action ID', - name: 'actionId', - type: 'string', - default: '', - required: true, - description: 'ID of the action to retrieve', - displayOptions: { show: { resource: ['action'], operation: ['get'] } }, - }, - - // ═══════════════════════════════════════ - // Get Actions by Actor ID fields - // ═══════════════════════════════════════ - { - displayName: 'Actor ID', - name: 'actorId', - type: 'string', - default: '', - required: true, - description: 'ID of the actor to retrieve actions for', - displayOptions: { show: { resource: ['action'], operation: ['getByActor'] } }, - }, - { - displayName: 'Since', - name: 'since', - type: 'dateTime', - default: '', - description: 'Filter actions created after this RFC 3339 timestamp', - displayOptions: { show: { resource: ['action'], operation: ['getByActor'] } }, - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - typeOptions: { minValue: 1, maxValue: 200 }, - default: 50, - description: 'Max number of results to return', - displayOptions: { show: { resource: ['action'], operation: ['getByActor'] } }, - }, - { - displayName: 'Workflow Instance ID', - name: 'workflowInstanceId', - type: 'string', - default: '', - description: 'Filter by workflow instance ID', - displayOptions: { show: { resource: ['action'], operation: ['getByActor'] } }, - }, + // ── Message fields ── + ...messageCreateProperties, + ...messageGetByActorProperties, + ...messageListProperties, - // ═══════════════════════════════════════ - // List Actions fields - // ═══════════════════════════════════════ - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - default: false, - description: 'Whether to return all results or only up to a given limit', - displayOptions: { show: { resource: ['action'], operation: ['list'] } }, - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - typeOptions: { minValue: 1, maxValue: 200 }, - default: 50, - description: 'Max number of results to return', - displayOptions: { show: { resource: ['action'], operation: ['list'], returnAll: [false] } }, - }, - { - displayName: 'Actor ID', - name: 'actorId', - type: 'string', - default: '', - description: 'Filter by actor ID', - displayOptions: { show: { resource: ['action'], operation: ['list'] } }, - }, - { - displayName: 'Workflow Instance ID', - name: 'workflowInstanceId', - type: 'string', - default: '', - description: 'Filter by workflow instance ID', - displayOptions: { show: { resource: ['action'], operation: ['list'] } }, - }, - { - displayName: 'Since', - name: 'since', - type: 'dateTime', - default: '', - description: 'Filter actions created after this RFC 3339 timestamp', - displayOptions: { show: { resource: ['action'], operation: ['list'] } }, - }, - - // ═══════════════════════════════════════ - // Update Action fields - // ═══════════════════════════════════════ - { - displayName: 'Action ID', - name: 'actionId', - type: 'string', - default: '', - required: true, - description: 'ID of the action to update', - displayOptions: { show: { resource: ['action'], operation: ['update'] } }, - }, - { - displayName: 'Status', - name: 'status', - type: 'options', - default: 'pending', - options: [ - { name: 'Cancelled', value: 'cancelled' }, - { name: 'Completed', value: 'completed' }, - { name: 'Deleted', value: 'deleted' }, - { name: 'Expired', value: 'expired' }, - { name: 'In Progress', value: 'in_progress' }, - { name: 'Pending', value: 'pending' }, - ], - description: 'New status for the action', - displayOptions: { show: { resource: ['action'], operation: ['update'] } }, - }, + // ── Action fields ── + ...actionCreateProperties, + ...actionGetProperties, + ...actionGetByActorProperties, + ...actionListProperties, + ...actionUpdateProperties, ], }; @@ -483,7 +110,7 @@ export class WorkflowInteractionLayer implements INodeType { const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - for (let i = 0; i < items.length; i++) { + for (const [i] of items.entries()) { try { let responseData: unknown; diff --git a/community-nodes/nodes/WorkflowInteractionLayer/operations/action.operations.ts b/community-nodes/nodes/WorkflowInteractionLayer/operations/action.operations.ts index 5a76527..6dd57fe 100644 --- a/community-nodes/nodes/WorkflowInteractionLayer/operations/action.operations.ts +++ b/community-nodes/nodes/WorkflowInteractionLayer/operations/action.operations.ts @@ -12,7 +12,6 @@ export async function createAction(ctx: IExecuteFunctions, i: number): Promise; const callbackMethod = ctx.getNodeParameter('callbackMethod', i) as ActionCreatePayload['callbackMethod']; - if (callbackMethod) body.callbackMethod = callbackMethod; - - const callbackPayloadSpec = safeParse(ctx.getNodeParameter('callbackPayloadSpec', i, '{}')); - if (callbackPayloadSpec) body.callbackPayloadSpec = callbackPayloadSpec as Record; + if (callbackMethod && callbackMethod !== 'none') { + body.callbackMethod = callbackMethod; + body.callbackUrl = ctx.getNodeParameter('callbackUrl', i) as string; + + const callbackPayloadSpec = safeParse(ctx.getNodeParameter('callbackPayloadSpec', i, '{}')); + if (callbackPayloadSpec) body.callbackPayloadSpec = callbackPayloadSpec as Record; + } else { + body.callbackMethod = 'none'; + body.callbackUrl = ''; + } const dueDate = ctx.getNodeParameter('dueDate', i, '') as string; if (dueDate) body.dueDate = dueDate; diff --git a/community-nodes/nodes/WorkflowInteractionLayer/shared/properties.ts b/community-nodes/nodes/WorkflowInteractionLayer/shared/properties.ts new file mode 100644 index 0000000..6edd9e2 --- /dev/null +++ b/community-nodes/nodes/WorkflowInteractionLayer/shared/properties.ts @@ -0,0 +1,300 @@ +import type { INodeProperties } from 'n8n-workflow'; + +type DisplayOptions = INodeProperties['displayOptions']; + +// ── Shared field factories ── + +function actorIdField(displayOptions: DisplayOptions, options?: Partial): INodeProperties { + return { + displayName: 'Actor ID', + name: 'actorId', + type: 'string', + default: '', + required: true, + description: 'Identifier for the target actor (max 50 characters)', + displayOptions, + ...options, + }; +} + +function actorTypeField(displayOptions: DisplayOptions): INodeProperties { + return { + displayName: 'Actor Type', + name: 'actorType', + type: 'options', + default: 'user', + required: true, + options: [ + { name: 'Group', value: 'group' }, + { name: 'Role', value: 'role' }, + { name: 'System', value: 'system' }, + { name: 'User', value: 'user' }, + { name: 'Other', value: 'other' }, + ], + displayOptions, + }; +} + +function sinceField(displayOptions: DisplayOptions, description: string): INodeProperties { + return { + displayName: 'Since', + name: 'since', + type: 'dateTime', + default: '', + description, + displayOptions, + }; +} + +function limitField(displayOptions: DisplayOptions): INodeProperties { + return { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { minValue: 1, maxValue: 200 }, + default: 50, + description: 'Max number of results to return', + displayOptions, + }; +} + +function workflowInstanceIdField(displayOptions: DisplayOptions): INodeProperties { + return { + displayName: 'Workflow Instance ID', + name: 'workflowInstanceId', + type: 'string', + default: '', + description: 'Filter by workflow instance ID', + displayOptions, + }; +} + +function metadataField(displayOptions: DisplayOptions): INodeProperties { + return { + displayName: 'Metadata', + name: 'metadata', + type: 'json', + default: '{}', + description: 'Optional JSON metadata object', + displayOptions, + }; +} + +function returnAllField(displayOptions: DisplayOptions): INodeProperties { + return { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions, + }; +} + +function autoFieldsNotice(name: string, displayOptions: DisplayOptions): INodeProperties { + return { + displayName: + 'Workflow ID and Workflow Instance ID are automatically set from the current workflow and execution context', + name, + type: 'notice', + default: '', + displayOptions, + }; +} + +// ── Composed property groups ── + +/** Fields shared by "getByActor" operations (message and action). */ +function getByActorFields(resource: string): INodeProperties[] { + const show = { resource: [resource], operation: ['getByActor'] }; + return [ + actorIdField({ show }, { description: `ID of the actor to retrieve ${resource}s for` }), + sinceField({ show }, `Filter ${resource}s created after this RFC 3339 timestamp`), + limitField({ show }), + workflowInstanceIdField({ show }), + ]; +} + +/** Fields shared by "list" operations (message and action). */ +function listFields(resource: string): INodeProperties[] { + const show = { resource: [resource], operation: ['list'] }; + const showWithLimit = { resource: [resource], operation: ['list'], returnAll: [false] }; + return [ + returnAllField({ show }), + limitField({ show: showWithLimit }), + actorIdField({ show }, { required: false, description: 'Filter by actor ID' }), + workflowInstanceIdField({ show }), + sinceField( + { show }, + `Filter ${resource}s created after this RFC 3339 timestamp${resource === 'message' ? ' (cursor for pagination)' : ''}`, + ), + ]; +} + +// ── Exported property arrays ── + +export const messageCreateProperties: INodeProperties[] = [ + autoFieldsNotice('autoFieldsNoticeMessage', { show: { resource: ['message'], operation: ['create'] } }), + actorIdField({ show: { resource: ['message'], operation: ['create'] } }), + actorTypeField({ show: { resource: ['message'], operation: ['create'] } }), + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + required: true, + description: 'Message title (max 255 characters)', + displayOptions: { show: { resource: ['message'], operation: ['create'] } }, + }, + { + displayName: 'Body', + name: 'body', + type: 'string', + typeOptions: { rows: 4 }, + default: '', + required: true, + description: 'Message body text', + displayOptions: { show: { resource: ['message'], operation: ['create'] } }, + }, + metadataField({ show: { resource: ['message'], operation: ['create'] } }), +]; + +export const messageGetByActorProperties: INodeProperties[] = getByActorFields('message'); + +export const messageListProperties: INodeProperties[] = listFields('message'); + +export const actionCreateProperties: INodeProperties[] = [ + autoFieldsNotice('autoFieldsNoticeAction', { show: { resource: ['action'], operation: ['create'] } }), + actorIdField({ show: { resource: ['action'], operation: ['create'] } }), + actorTypeField({ show: { resource: ['action'], operation: ['create'] } }), + { + displayName: 'Action Type', + name: 'actionType', + type: 'options', + default: 'getapproval', + required: true, + options: [ + { name: 'Get Approval', value: 'getapproval' }, + { name: 'Show Form', value: 'showform' }, + { name: 'Wait on Event', value: 'waitonevent' }, + ], + description: 'The type of action to create', + displayOptions: { show: { resource: ['action'], operation: ['create'] } }, + }, + { + displayName: 'Payload', + name: 'payload', + type: 'json', + default: '{}', + required: true, + description: 'For "showform": include formId, formVersion, returnUrl. For "getapproval": free-form JSON.', + displayOptions: { show: { resource: ['action'], operation: ['create'] } }, + }, + { + displayName: 'Callback Method', + name: 'callbackMethod', + type: 'options', + default: 'POST', + options: [ + { name: 'None', value: 'none' }, + { name: 'POST', value: 'POST' }, + { name: 'PUT', value: 'PUT' }, + { name: 'PATCH', value: 'PATCH' }, + ], + description: 'HTTP method for the callback. Select "None" if no callback is needed.', + displayOptions: { show: { resource: ['action'], operation: ['create'] } }, + }, + { + displayName: 'Callback URL', + name: 'callbackUrl', + type: 'string', + default: '', + required: true, + description: 'URL to call when the action is completed', + displayOptions: { + show: { resource: ['action'], operation: ['create'], callbackMethod: ['POST', 'PUT', 'PATCH'] }, + }, + }, + { + displayName: 'Callback Payload Spec', + name: 'callbackPayloadSpec', + type: 'json', + default: '{}', + description: 'Optional template for expected callback body', + displayOptions: { + show: { resource: ['action'], operation: ['create'], callbackMethod: ['POST', 'PUT', 'PATCH'] }, + }, + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + description: 'Optional due date in RFC 3339 format', + displayOptions: { show: { resource: ['action'], operation: ['create'] } }, + }, + { + displayName: 'Priority', + name: 'priority', + type: 'options', + default: 'normal', + options: [ + { name: 'Critical', value: 'critical' }, + { name: 'Normal', value: 'normal' }, + ], + displayOptions: { show: { resource: ['action'], operation: ['create'] } }, + }, + { + displayName: 'Check In', + name: 'checkIn', + type: 'dateTime', + default: '', + description: 'Optional reminder timestamp in RFC 3339 format', + displayOptions: { show: { resource: ['action'], operation: ['create'] } }, + }, + metadataField({ show: { resource: ['action'], operation: ['create'] } }), +]; + +export const actionGetProperties: INodeProperties[] = [ + { + displayName: 'Action ID', + name: 'actionId', + type: 'string', + default: '', + required: true, + description: 'ID of the action to retrieve', + displayOptions: { show: { resource: ['action'], operation: ['get'] } }, + }, +]; + +export const actionGetByActorProperties: INodeProperties[] = getByActorFields('action'); + +export const actionListProperties: INodeProperties[] = listFields('action'); + +export const actionUpdateProperties: INodeProperties[] = [ + { + displayName: 'Action ID', + name: 'actionId', + type: 'string', + default: '', + required: true, + description: 'ID of the action to update', + displayOptions: { show: { resource: ['action'], operation: ['update'] } }, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + default: 'pending', + options: [ + { name: 'Cancelled', value: 'cancelled' }, + { name: 'Completed', value: 'completed' }, + { name: 'Deleted', value: 'deleted' }, + { name: 'Expired', value: 'expired' }, + { name: 'In Progress', value: 'in_progress' }, + { name: 'Pending', value: 'pending' }, + ], + description: 'New status for the action', + displayOptions: { show: { resource: ['action'], operation: ['update'] } }, + }, +]; diff --git a/community-nodes/nodes/WorkflowInteractionLayer/shared/types.ts b/community-nodes/nodes/WorkflowInteractionLayer/shared/types.ts index ab44301..6d9caef 100644 --- a/community-nodes/nodes/WorkflowInteractionLayer/shared/types.ts +++ b/community-nodes/nodes/WorkflowInteractionLayer/shared/types.ts @@ -28,8 +28,8 @@ export interface ActionCreatePayload { actorType: 'user' | 'group' | 'role' | 'system' | 'other'; actionType: string; payload: Record; - callbackUrl: string; - callbackMethod?: 'POST' | 'PUT' | 'PATCH'; + callbackUrl?: string; + callbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'none'; callbackPayloadSpec?: Record; workflowId: string; dueDate?: string; diff --git a/docker-compose/sdg-mock-app/Dockerfile b/docker-compose/sdg-mock-app/Dockerfile index 41e00ff..c340f69 100644 --- a/docker-compose/sdg-mock-app/Dockerfile +++ b/docker-compose/sdg-mock-app/Dockerfile @@ -2,11 +2,11 @@ FROM node:24-alpine AS base RUN corepack enable && corepack prepare pnpm@11.0.8 --activate FROM base AS deps -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache g++ libc6-compat make python3 WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN pnpm i --frozen-lockfile --ignore-scripts +RUN pnpm i --frozen-lockfile FROM base AS builder WORKDIR /app diff --git a/docker-compose/sdg-mock-app/next.config.ts b/docker-compose/sdg-mock-app/next.config.ts index 42d0e13..29da86e 100644 --- a/docker-compose/sdg-mock-app/next.config.ts +++ b/docker-compose/sdg-mock-app/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { outputFileTracingRoot: __dirname, distDir: '.next', + serverExternalPackages: ['better-sqlite3'], }; export default nextConfig; diff --git a/docker-compose/sdg-mock-app/pnpm-workspace.yaml b/docker-compose/sdg-mock-app/pnpm-workspace.yaml index ab31a5f..adee2db 100644 --- a/docker-compose/sdg-mock-app/pnpm-workspace.yaml +++ b/docker-compose/sdg-mock-app/pnpm-workspace.yaml @@ -1,6 +1,6 @@ # pnpm-workspace.yaml allowBuilds: - better-sqlite3: false + better-sqlite3: true sharp: false unrs-resolver: false confirmModulesPurge: false diff --git a/docker-compose/sdg-mock-app/src/app/api/wil/callback/route.ts b/docker-compose/sdg-mock-app/src/app/api/wil/callback/route.ts index 015822a..89b8ab0 100644 --- a/docker-compose/sdg-mock-app/src/app/api/wil/callback/route.ts +++ b/docker-compose/sdg-mock-app/src/app/api/wil/callback/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { wilCallback, wilGetAction } from '@/lib/wil-proxy'; +import { wilCallback, wilGetAction, wilPatch } from '@/lib/wil-proxy'; import { requirePlaygroundConfigFromHeader } from '@/lib/playground-resolve'; /** @@ -32,13 +32,39 @@ export async function POST(request: NextRequest) { } const action = await actionResp.json(); + const callbackMethod = ((action.callbackMethod as string) || '').toUpperCase(); const callbackUrl = action.callbackUrl as string | undefined; - if (!callbackUrl) { - return NextResponse.json({ error: 'Action has no callbackUrl' }, { status: 404 }); + + // When callbackMethod is "None", skip the callback and just mark the action completed + if (callbackMethod === 'NONE' || !callbackUrl) { + const patchResp = await wilPatch( + `/actions/${encodeURIComponent(actionId)}`, + { status: 'completed' }, + resolved.config, + ); + if (!patchResp.ok) { + const errBody = await patchResp.text(); + return NextResponse.json( + { error: `Failed to mark action ${actionId} as completed: ${errBody}` }, + { status: patchResp.status }, + ); + } + return NextResponse.json({ completed: true, actionId }); } - const method = ((action.callbackMethod as string) || 'POST').toUpperCase(); - const upstream = await wilCallback(callbackUrl, method, body ?? {}, resolved.config); + const upstream = await wilCallback(callbackUrl, callbackMethod || 'POST', body ?? {}, resolved.config); + + // After a successful callback, mark the action as completed + if (upstream.ok) { + const patchResp = await wilPatch( + `/actions/${encodeURIComponent(actionId)}`, + { status: 'completed' }, + resolved.config, + ); + if (!patchResp.ok) { + console.error(`[callback] Failed to mark action ${actionId} as completed: ${patchResp.status}`); + } + } // Try to return JSON; fall back to text const contentType = upstream.headers.get('content-type') || ''; diff --git a/docker-compose/sdg-mock-app/src/components/ActionsPanel.tsx b/docker-compose/sdg-mock-app/src/components/ActionsPanel.tsx index 96d970c..5b3b662 100644 --- a/docker-compose/sdg-mock-app/src/components/ActionsPanel.tsx +++ b/docker-compose/sdg-mock-app/src/components/ActionsPanel.tsx @@ -94,6 +94,18 @@ function ActionCard({ action: a, actorId, toast, onRefresh }: ActionCardProps) { const payloadFormName = a.actionType === 'showform' ? ((a.payload?.FormName ?? a.payload?.formName) as string | undefined) : undefined; + // Resolve submissionId from payload (for loading existing submissions) + const payloadSubmissionId = + a.actionType === 'showform' + ? ((a.payload?.FormSubmissionId ?? a.payload?.formSubmissionId) as string | undefined) + : undefined; + + // Resolve prefill data from payload (for fresh forms with pre-populated fields) + const payloadPrefillData = + a.actionType === 'showform' + ? ((a.payload?.FormPreFillData ?? a.payload?.formPreFillData) as Record | undefined) + : undefined; + const isShowForm = a.actionType === 'showform' && ['pending', 'in_progress'].includes(a.status) && !!showFormId; const handleOpenForm = useCallback(async () => { @@ -292,6 +304,8 @@ function ActionCard({ action: a, actorId, toast, onRefresh }: ActionCardProps) { formName={formName ?? undefined} token={formToken} chefsBaseUrl={formChefsBaseUrl ?? undefined} + submissionId={payloadSubmissionId} + prefillData={payloadPrefillData} onClose={() => { setFormModalOpen(false); setFormToken(null); diff --git a/docker-compose/sdg-mock-app/src/components/ChefsFormModal.tsx b/docker-compose/sdg-mock-app/src/components/ChefsFormModal.tsx index 037d5d2..e34324b 100644 --- a/docker-compose/sdg-mock-app/src/components/ChefsFormModal.tsx +++ b/docker-compose/sdg-mock-app/src/components/ChefsFormModal.tsx @@ -8,6 +8,8 @@ interface ChefsFormModalProps { formName?: string; token: string; chefsBaseUrl?: string; + submissionId?: string; + prefillData?: Record; onClose: () => void; onSubmitted: (detail: unknown) => void; } @@ -17,6 +19,8 @@ export default function ChefsFormModal({ formName, token, chefsBaseUrl, + submissionId, + prefillData, onClose, onSubmitted, }: Readonly) { @@ -73,6 +77,8 @@ export default function ChefsFormModal({ formId={formId} authToken={token} baseUrl={chefsBaseUrl || undefined} + submissionId={submissionId} + prefillData={prefillData} onSubmissionComplete={handleSubmissionComplete} onSubmissionError={(err) => { console.error('Form submission error:', err); diff --git a/docker-compose/sdg-mock-app/src/components/chefs/ChefsFormViewer.tsx b/docker-compose/sdg-mock-app/src/components/chefs/ChefsFormViewer.tsx index 6480c44..cdaaacb 100644 --- a/docker-compose/sdg-mock-app/src/components/chefs/ChefsFormViewer.tsx +++ b/docker-compose/sdg-mock-app/src/components/chefs/ChefsFormViewer.tsx @@ -10,6 +10,7 @@ export function ChefsFormViewer({ apiKey, headers, submissionId, + prefillData, readOnly = false, language = 'en', isolateStyles = false, @@ -17,11 +18,13 @@ export function ChefsFormViewer({ onFormReady, onSubmissionComplete, onSubmissionError, -}: ChefsFormViewerProps) { +}: Readonly) { const containerRef = useRef(null); const scriptStatus = useChefsScript(); const [isFormMounted, setIsFormMounted] = useState(false); + console.log('[ChefsFormViewer] Render', { scriptStatus, formId, isFormMounted, hasPrefillData: !!prefillData }); + const headersAttr = headers ? `headers="${encodeURIComponent(JSON.stringify(headers))}"` : ''; const attrs = [ @@ -94,12 +97,59 @@ export function ChefsFormViewer({ useEffect(() => { if (scriptStatus !== 'ready' || !containerRef.current) return; + console.log('[ChefsFormViewer] Main effect running', { + scriptStatus, + formId, + hasPrefillData: !!prefillData, + prefillKeys: prefillData ? Object.keys(prefillData) : [], + submissionId: submissionId ?? null, + }); + const formViewer = containerRef.current.querySelector('chefs-form-viewer'); - if (!formViewer) return; + if (!formViewer) { + console.warn('[ChefsFormViewer] No element found in container'); + return; + } - const handleFormReady = () => { + const handleFormReady = (event: Event) => { setIsFormMounted(true); - onFormReady?.({ formio: null }); + const customEvent = event as CustomEvent; + const formioInstance = customEvent.detail?.form ?? customEvent.detail; + + console.log('[ChefsFormViewer] formio:ready fired', { + hasDetail: !!customEvent.detail, + detailKeys: customEvent.detail ? Object.keys(customEvent.detail) : [], + formioInstance: !!formioInstance, + formioType: typeof formioInstance, + hasSubmissionProp: formioInstance && typeof formioInstance === 'object' && 'submission' in formioInstance, + }); + + // Apply prefill data for fresh forms (no submissionId) + if (prefillData && !submissionId) { + console.log('[ChefsFormViewer] Attempting prefill via setSubmission', { + prefillKeys: Object.keys(prefillData), + prefillData, + }); + + const viewer = formViewer as unknown as { setSubmission?: (data: Record) => void }; + if (typeof viewer.setSubmission === 'function') { + console.log('[ChefsFormViewer] Calling setSubmission() on web component'); + viewer.setSubmission(prefillData); + } else { + console.warn('[ChefsFormViewer] setSubmission not available on web component, trying direct assignment'); + // Last resort: direct assignment on the formio instance + if (formioInstance && typeof formioInstance === 'object' && 'submission' in formioInstance) { + formioInstance.submission = { data: { ...prefillData } }; + } + } + } else { + console.log('[ChefsFormViewer] Skipping prefill', { + hasPrefillData: !!prefillData, + hasSubmissionId: !!submissionId, + }); + } + + onFormReady?.({ formio: formioInstance }); }; const handleSubmit = (event: Event) => { @@ -118,7 +168,22 @@ export function ChefsFormViewer({ const shadowRoot = (formViewer as HTMLElement & { shadowRoot: ShadowRoot | null }).shadowRoot; if (shadowRoot && shadowRoot.children.length > 0) { - queueMicrotask(() => setIsFormMounted(true)); + console.log('[ChefsFormViewer] Shadow root already present, applying prefill via queueMicrotask'); + queueMicrotask(() => { + setIsFormMounted(true); + // If form is already ready, apply prefill data now + if (prefillData && !submissionId) { + const viewer = formViewer as unknown as { setSubmission?: (data: Record) => void }; + if (typeof viewer.setSubmission === 'function') { + console.log('[ChefsFormViewer] queueMicrotask: calling setSubmission()'); + viewer.setSubmission(prefillData); + } else { + console.warn('[ChefsFormViewer] queueMicrotask: setSubmission not available'); + } + } + }); + } else { + console.log('[ChefsFormViewer] No shadow root yet, waiting for formio:ready event'); } if (headers) { @@ -135,7 +200,7 @@ export function ChefsFormViewer({ formViewer.removeEventListener('formio:submitDone', handleSubmit); formViewer.removeEventListener('formio:submitError', handleSubmitError); }; - }, [scriptStatus, headers, onFormReady, onSubmissionComplete, onSubmissionError]); + }, [scriptStatus, headers, prefillData, submissionId, onFormReady, onSubmissionComplete, onSubmissionError]); if (scriptStatus === 'error') { return ( diff --git a/docker-compose/sdg-mock-app/src/components/chefs/types.ts b/docker-compose/sdg-mock-app/src/components/chefs/types.ts index e43b144..d94000b 100644 --- a/docker-compose/sdg-mock-app/src/components/chefs/types.ts +++ b/docker-compose/sdg-mock-app/src/components/chefs/types.ts @@ -5,6 +5,7 @@ export interface ChefsFormViewerProps { apiKey?: string; headers?: Record; submissionId?: string; + prefillData?: Record; readOnly?: boolean; language?: string; isolateStyles?: boolean; diff --git a/docker-compose/sdg-mock-app/src/components/chefs/use-chefs-script.hook.ts b/docker-compose/sdg-mock-app/src/components/chefs/use-chefs-script.hook.ts index bff847a..804780c 100644 --- a/docker-compose/sdg-mock-app/src/components/chefs/use-chefs-script.hook.ts +++ b/docker-compose/sdg-mock-app/src/components/chefs/use-chefs-script.hook.ts @@ -13,14 +13,17 @@ export function useChefsScript(): ScriptStatus { }); useEffect(() => { + console.log('[useChefsScript] Hook running, current status:', status); let script = document.querySelector(`script[src="${SCRIPT_URL}"]`) as HTMLScriptElement | null; if (script?.dataset.status === 'ready') { + console.log('[useChefsScript] Script already loaded'); setStatus('ready'); return; } if (!script) { + console.log('[useChefsScript] Injecting script tag:', SCRIPT_URL); script = document.createElement('script'); script.src = SCRIPT_URL; script.async = true; @@ -31,10 +34,12 @@ export function useChefsScript(): ScriptStatus { setStatus('loading'); const onLoad = () => { + console.log('[useChefsScript] Script loaded successfully'); script!.dataset.status = 'ready'; setStatus('ready'); }; const onError = () => { + console.error('[useChefsScript] Script failed to load'); script!.dataset.status = 'error'; setStatus('error'); }; diff --git a/docs/community-nodes/workflow-interaction-layer/api-reference.md b/docs/community-nodes/workflow-interaction-layer/api-reference.md index 004cf9a..94b247e 100644 --- a/docs/community-nodes/workflow-interaction-layer/api-reference.md +++ b/docs/community-nodes/workflow-interaction-layer/api-reference.md @@ -111,21 +111,23 @@ POST /rest/custom/v1/actions **Request Body:** -| Field | Type | Required | Description | -| --------------------- | ------ | -------- | -------------------------------------------------- | -| `workflowInstanceId` | string | Yes | Current execution ID | -| `actorId` | string | Yes | Target actor identifier (max 50 chars) | -| `actorType` | string | Yes | One of: `user`, `group`, `role`, `system`, `other` | -| `actionType` | string | Yes | One of: `getapproval`, `showform`, `waitonevent` | -| `payload` | object | Yes | Action-specific data (see below) | -| `callbackUrl` | string | Yes | URL to call when action completes | -| `callbackMethod` | string | No | `POST` (default), `PUT`, or `PATCH` | -| `callbackPayloadSpec` | object | No | Template describing expected callback body | -| `workflowId` | string | Yes | Source workflow ID | -| `dueDate` | string | No | RFC 3339 timestamp | -| `priority` | string | No | `normal` (default) or `critical` | -| `checkIn` | string | No | RFC 3339 reminder timestamp | -| `metadata` | object | No | Arbitrary JSON metadata | +| Field | Type | Required | Description | +| --------------------- | ------ | -------- | --------------------------------------------------------- | +| `workflowInstanceId` | string | Yes | Current execution ID | +| `actorId` | string | Yes | Target actor identifier (max 50 chars) | +| `actorType` | string | Yes | One of: `user`, `group`, `role`, `system`, `other` | +| `actionType` | string | Yes | One of: `getapproval`, `showform`, `waitonevent` | +| `payload` | object | Yes | Action-specific data (see below) | +| `callbackUrl` | string | No | URL to call when action completes (omit when no callback) | +| `callbackMethod` | string | No | `POST` (default), `PUT`, `PATCH`, or `none` | +| `callbackPayloadSpec` | object | No | Template describing expected callback body | +| `workflowId` | string | Yes | Source workflow ID | +| `dueDate` | string | No | RFC 3339 timestamp | +| `priority` | string | No | `normal` (default) or `critical` | +| `checkIn` | string | No | RFC 3339 reminder timestamp | +| `metadata` | object | No | Arbitrary JSON metadata | + +> **Note:** When `callbackMethod` is `none` (or omitted along with `callbackUrl`), no callback will be issued upon action completion. The `callbackUrl` and `callbackPayloadSpec` fields should be omitted from the request body in this case. **Payload by Action Type:** diff --git a/docs/community-nodes/workflow-interaction-layer/node-operations.md b/docs/community-nodes/workflow-interaction-layer/node-operations.md index 2cbf35d..f1ed018 100644 --- a/docs/community-nodes/workflow-interaction-layer/node-operations.md +++ b/docs/community-nodes/workflow-interaction-layer/node-operations.md @@ -60,19 +60,24 @@ Returns all messages for a specific actor. Creates a new action in the WIL-API Layer. -| Parameter | Type | Required | Default | Description | -| --------------------- | ------- | -------- | ------------- | ------------------------------------------ | -| Actor ID | string | Yes | — | Target actor identifier (max 50 chars) | -| Actor Type | options | Yes | `user` | `user`, `group`, `role`, `system`, `other` | -| Action Type | options | Yes | `getapproval` | `getapproval`, `showform`, `waitonevent` | -| Payload | JSON | Yes | `{}` | Action-specific data | -| Callback URL | string | Yes | — | URL called when action completes | -| Callback Method | options | No | `POST` | `POST`, `PUT`, `PATCH` | -| Callback Payload Spec | JSON | No | `{}` | Template for expected callback body | -| Due Date | string | No | — | RFC 3339 timestamp | -| Priority | options | No | `normal` | `normal` or `critical` | -| Check In | string | No | — | RFC 3339 reminder timestamp | -| Metadata | JSON | No | `{}` | Arbitrary JSON metadata | +| Parameter | Type | Required | Default | Description | +| --------------------- | ------- | --------------------------------- | ------------- | ------------------------------------------------------------------ | +| Actor ID | string | Yes | — | Target actor identifier (max 50 chars) | +| Actor Type | options | Yes | `user` | `user`, `group`, `role`, `system`, `other` | +| Action Type | options | Yes | `getapproval` | `getapproval`, `showform`, `waitonevent` | +| Payload | JSON | Yes | `{}` | Action-specific data | +| Callback Method | options | No | `POST` | `none`, `POST`, `PUT`, `PATCH` | +| Callback URL | string | Yes (when Callback Method ≠ None) | — | URL called when action completes. Hidden when method is "None". | +| Callback Payload Spec | JSON | No | `{}` | Template for expected callback body. Hidden when method is "None". | +| Due Date | string | No | — | RFC 3339 timestamp | +| Priority | options | No | `normal` | `normal` or `critical` | +| Check In | string | No | — | RFC 3339 reminder timestamp | +| Metadata | JSON | No | `{}` | Arbitrary JSON metadata | + +**Callback Method Behavior:** + +- **None** — No callback will be issued when the action completes. The Callback URL and Callback Payload Spec fields are hidden from the UI and omitted from the API request. +- **POST / PUT / PATCH** — The specified HTTP method is used to call the Callback URL upon action completion. The Callback URL field becomes required. **Action Type Guidance:** diff --git a/external-hooks/src/api/constants/enum.ts b/external-hooks/src/api/constants/enum.ts index 5a93006..96cb062 100644 --- a/external-hooks/src/api/constants/enum.ts +++ b/external-hooks/src/api/constants/enum.ts @@ -32,6 +32,6 @@ export type ActionRequestPriority = (typeof ACTION_REQUEST_PRIORITY_VALUES)[numb export const actionRequestPriorityZodEnum = z.enum(ACTION_REQUEST_PRIORITY_VALUES); /** Allowed HTTP verbs for action callback delivery. */ -export const CALLBACK_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +export const CALLBACK_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'NONE'] as const; export type CallbackHttpMethod = (typeof CALLBACK_HTTP_METHODS)[number]; export const callbackHttpMethodZodEnum = z.enum(CALLBACK_HTTP_METHODS); diff --git a/external-hooks/src/api/routes/actions.ts b/external-hooks/src/api/routes/actions.ts index d6dd6da..a17a351 100644 --- a/external-hooks/src/api/routes/actions.ts +++ b/external-hooks/src/api/routes/actions.ts @@ -47,6 +47,7 @@ export function buildActionRouter({ const { dueDate, checkIn } = normalizeCreateActionTimestamps(body); const callbackMethod = body.callbackMethod ?? 'POST'; + const callbackUrl = callbackMethod === 'NONE' ? '' : (body.callbackUrl ?? ''); const projectId = await resolveProjectIdForCreate({ executionRepository: execution, @@ -61,7 +62,7 @@ export function buildActionRouter({ const created = await actionRequestRepository.create({ actionType: body.actionType, payload: body.payload, - callbackUrl: body.callbackUrl, + callbackUrl, callbackMethod, callbackPayloadSpec: body.callbackPayloadSpec ?? null, actorId: body.actorId, diff --git a/external-hooks/src/api/schemas/action-request.ts b/external-hooks/src/api/schemas/action-request.ts index df93581..4a6c46e 100644 --- a/external-hooks/src/api/schemas/action-request.ts +++ b/external-hooks/src/api/schemas/action-request.ts @@ -16,7 +16,7 @@ export const actionRequestItemSchema = z.object({ actionType: z.string(), payload: z.record(z.string(), z.unknown()), callbackUrl: z.string(), - callbackMethod: callbackHttpMethodZodEnum, + callbackMethod: z.string(), callbackPayloadSpec: z.record(z.string(), z.unknown()).nullable(), actorId: z.string(), actorType: z.string(), @@ -120,7 +120,7 @@ export const createActionRequestSchema = z .object({ actionType: z.string().trim().min(1), payload: z.record(z.string(), z.unknown()), - callbackUrl: z.string().trim().min(1), + callbackUrl: z.string().trim().optional().default(''), callbackMethod: z .string() .trim() @@ -141,7 +141,17 @@ export const createActionRequestSchema = z .strict(), }) .superRefine((data, ctx) => { - const { dueDate, checkIn } = data.body; + const { callbackUrl, callbackMethod, dueDate, checkIn } = data.body; + const method = callbackMethod ?? 'POST'; + + if (method !== 'NONE' && (!callbackUrl || callbackUrl.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'callbackUrl is required when callbackMethod is not NONE', + path: ['body', 'callbackUrl'], + }); + } + if (dueDate !== undefined && dueDate !== null) { const d = parseOptionalBodyTimestamp(dueDate); if (d === undefined) { diff --git a/external-hooks/tests/api/schemas/action-request.test.ts b/external-hooks/tests/api/schemas/action-request.test.ts index f99a68d..8d3c201 100644 --- a/external-hooks/tests/api/schemas/action-request.test.ts +++ b/external-hooks/tests/api/schemas/action-request.test.ts @@ -52,8 +52,12 @@ describe('createActionRequestSchema', () => { }); it('accepts all valid callbackMethod values', () => { - for (const method of ['get', 'post', 'put', 'patch', 'delete']) { - const result = createActionRequestSchema.parse({ body: { ...validBody, callbackMethod: method } }); + for (const method of ['get', 'post', 'put', 'patch', 'delete', 'none']) { + const body = + method === 'none' + ? { ...validBody, callbackMethod: method, callbackUrl: '' } + : { ...validBody, callbackMethod: method }; + const result = createActionRequestSchema.parse({ body }); expect(result.body.callbackMethod).toBe(method.toUpperCase()); } }); @@ -62,6 +66,20 @@ describe('createActionRequestSchema', () => { expect(() => createActionRequestSchema.parse({ body: { ...validBody, callbackMethod: 'OPTIONS' } })).toThrow(); }); + it('allows empty callbackUrl when callbackMethod is none', () => { + const result = createActionRequestSchema.parse({ + body: { ...validBody, callbackUrl: '', callbackMethod: 'none' }, + }); + expect(result.body.callbackMethod).toBe('NONE'); + expect(result.body.callbackUrl).toBe(''); + }); + + it('rejects empty callbackUrl when callbackMethod is not none', () => { + expect(() => + createActionRequestSchema.parse({ body: { ...validBody, callbackUrl: '', callbackMethod: 'post' } }), + ).toThrow(); + }); + it('accepts valid dueDate string', () => { const result = createActionRequestSchema.parse({ body: { ...validBody, dueDate: '2025-12-31T00:00:00Z' },