Add Activepieces integration for workflow automation
- Add Activepieces fork with SmoothSchedule custom piece - Create integrations app with Activepieces service layer - Add embed token endpoint for iframe integration - Create Automations page with embedded workflow builder - Add sidebar visibility fix for embed mode - Add list inactive customers endpoint to Public API - Include SmoothSchedule triggers: event created/updated/cancelled - Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
createAction,
|
||||
DynamicPropsValue,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { drupal } from '../common/jsonapi';
|
||||
import {
|
||||
fetchEntityTypesForEditing,
|
||||
buildFieldProperties
|
||||
} from '../common/drupal-entities';
|
||||
|
||||
type DrupalAuthType = PiecePropValueSchema<typeof drupalAuth>;
|
||||
|
||||
export const drupalCreateEntityAction = createAction({
|
||||
auth: drupalAuth,
|
||||
name: 'drupal-create-entity',
|
||||
displayName: 'Create Entity',
|
||||
description: 'Create a new entity in Drupal with smart field discovery and validation',
|
||||
props: {
|
||||
entity_type: Property.Dropdown({
|
||||
auth: drupalAuth,
|
||||
displayName: 'Entity Type',
|
||||
description: 'Choose the type of content to create.',
|
||||
required: true,
|
||||
refreshers: [],
|
||||
options: async ({ auth }) => fetchEntityTypesForEditing(auth),
|
||||
}),
|
||||
entity_fields: Property.DynamicProperties({
|
||||
auth: drupalAuth,
|
||||
displayName: 'Entity Fields',
|
||||
description: 'Fill in the content fields. Available fields depend on the entity type selected above.',
|
||||
required: false,
|
||||
refreshers: ['entity_type'],
|
||||
props: async (propsValue) => {
|
||||
|
||||
const { auth, entity_type } = propsValue;
|
||||
if (!auth) {
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Please configure authentication first',
|
||||
};
|
||||
}
|
||||
return buildFieldProperties(auth, entity_type, true);
|
||||
}
|
||||
}),
|
||||
},
|
||||
async run({ auth, propsValue }) {
|
||||
const entityInfo = propsValue['entity_type'] as any;
|
||||
const fieldsData = propsValue['entity_fields'] as any;
|
||||
|
||||
// Extract field values, handling text fields with format correctly
|
||||
const fieldsToCreate: Record<string, any> = {};
|
||||
const processedFormatFields = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(fieldsData)) {
|
||||
// Skip empty values and already processed format fields
|
||||
if (value === undefined || value === null || value === '' || processedFormatFields.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle format fields (they should be combined with their text field)
|
||||
if (key.endsWith('_format')) {
|
||||
const textFieldName = key.replace('_format', '');
|
||||
const textValue = fieldsData[textFieldName];
|
||||
|
||||
if (textValue) {
|
||||
fieldsToCreate[textFieldName] = {
|
||||
value: textValue,
|
||||
format: value
|
||||
};
|
||||
processedFormatFields.add(textFieldName);
|
||||
}
|
||||
processedFormatFields.add(key);
|
||||
}
|
||||
// Handle text fields (check if they have a format)
|
||||
else {
|
||||
const formatKey = `${key}_format`;
|
||||
const formatValue = fieldsData[formatKey];
|
||||
|
||||
if (formatValue && formatValue !== 'undefined') {
|
||||
fieldsToCreate[key] = {
|
||||
value: value,
|
||||
format: formatValue
|
||||
};
|
||||
processedFormatFields.add(formatKey);
|
||||
} else {
|
||||
fieldsToCreate[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(fieldsToCreate).length === 0) {
|
||||
throw new Error('At least one field must be provided to create an entity');
|
||||
}
|
||||
|
||||
return await drupal.createEntity(
|
||||
auth,
|
||||
entityInfo.entity_type,
|
||||
entityInfo.bundle,
|
||||
fieldsToCreate
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
createAction,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { drupal } from '../common/jsonapi';
|
||||
import { fetchEntityTypesForReading } from '../common/drupal-entities';
|
||||
|
||||
type DrupalAuthType = PiecePropValueSchema<typeof drupalAuth>;
|
||||
|
||||
export const drupalDeleteEntityAction = createAction({
|
||||
auth: drupalAuth,
|
||||
name: 'drupal-delete-entity',
|
||||
displayName: 'Delete Entity',
|
||||
description: 'Delete an entity from Drupal',
|
||||
props: {
|
||||
entity_type: Property.Dropdown({
|
||||
displayName: 'Entity Type',
|
||||
description: 'Choose the type of content to delete.',
|
||||
required: true,
|
||||
refreshers: [],
|
||||
auth: drupalAuth,
|
||||
options: async ({ auth }) => fetchEntityTypesForReading(auth),
|
||||
}),
|
||||
entity_uuid: Property.ShortText({
|
||||
displayName: 'Entity UUID',
|
||||
description: 'The unique identifier (UUID) of the specific content item to delete.',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
async run({ auth, propsValue }) {
|
||||
const entityInfo = propsValue.entity_type as any;
|
||||
|
||||
return await drupal.deleteEntity(
|
||||
auth,
|
||||
entityInfo.entity_type,
|
||||
entityInfo.bundle,
|
||||
propsValue.entity_uuid
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
createAction,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { drupal } from '../common/jsonapi';
|
||||
import { fetchEntityTypesForReading } from '../common/drupal-entities';
|
||||
|
||||
type DrupalAuthType = PiecePropValueSchema<typeof drupalAuth>;
|
||||
|
||||
export const drupalGetEntityAction = createAction({
|
||||
auth: drupalAuth,
|
||||
name: 'drupal-get-entity',
|
||||
displayName: 'Get Entity',
|
||||
description: 'Retrieve a single entity by UUID',
|
||||
props: {
|
||||
entity_type: Property.Dropdown({
|
||||
displayName: 'Entity Type',
|
||||
description: 'Choose the type of content to retrieve.',
|
||||
required: true,
|
||||
refreshers: [],
|
||||
auth: drupalAuth,
|
||||
options: async ({ auth }) => fetchEntityTypesForReading(auth),
|
||||
}),
|
||||
entity_uuid: Property.ShortText({
|
||||
displayName: 'Entity UUID',
|
||||
description: 'The unique identifier (UUID) of the specific content item to retrieve.',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
async run({ auth, propsValue }) {
|
||||
const entityInfo = propsValue.entity_type as any;
|
||||
|
||||
return await drupal.getEntity(
|
||||
auth,
|
||||
entityInfo.entity_type,
|
||||
entityInfo.bundle,
|
||||
propsValue.entity_uuid
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
createAction,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { drupal } from '../common/jsonapi';
|
||||
import { fetchEntityTypesForReading } from '../common/drupal-entities';
|
||||
|
||||
type DrupalAuthType = PiecePropValueSchema<typeof drupalAuth>;
|
||||
|
||||
export const drupalListEntitiesAction = createAction({
|
||||
auth: drupalAuth,
|
||||
name: 'drupal-list-entities',
|
||||
displayName: 'List Entities',
|
||||
description: 'List entities from Drupal using JSON:API',
|
||||
props: {
|
||||
entity_type: Property.Dropdown({
|
||||
displayName: 'Entity Type',
|
||||
description: 'Choose what type of content to list.',
|
||||
required: true,
|
||||
refreshers: [],
|
||||
auth: drupalAuth,
|
||||
options: async ({ auth }) => fetchEntityTypesForReading(auth),
|
||||
}),
|
||||
published_status: Property.StaticDropdown({
|
||||
displayName: 'Published Status',
|
||||
description: 'Filter by publication status',
|
||||
required: false,
|
||||
defaultValue: 'all',
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Published only', value: 'published' },
|
||||
{ label: 'Unpublished only', value: 'unpublished' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
sort_by: Property.DynamicProperties({
|
||||
displayName: 'Sort Options',
|
||||
description: 'Choose how to sort the entities',
|
||||
required: false,
|
||||
refreshers: ['entity_type'],
|
||||
auth: drupalAuth,
|
||||
props: async (propsValue) => {
|
||||
const entityInfo = propsValue['entity_type'] as any;
|
||||
if (!entityInfo) return {} as any;
|
||||
|
||||
let sortOptions: Array<{label: string; value: string}> = [];
|
||||
|
||||
if (entityInfo.entity_type === 'taxonomy_term') {
|
||||
sortOptions = [
|
||||
{ label: 'Updated date', value: 'changed' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
];
|
||||
} else if (entityInfo.entity_type === 'user') {
|
||||
sortOptions = [
|
||||
{ label: 'Creation date', value: 'created' },
|
||||
{ label: 'Updated date', value: 'changed' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
];
|
||||
} else {
|
||||
sortOptions = [
|
||||
{ label: 'Creation date', value: 'created' },
|
||||
{ label: 'Updated date', value: 'changed' },
|
||||
{ label: 'Title', value: 'title' },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
sort_field: Property.StaticDropdown({
|
||||
displayName: 'Sort By',
|
||||
required: false,
|
||||
defaultValue: sortOptions[0].value,
|
||||
options: { options: sortOptions }
|
||||
})
|
||||
} as any;
|
||||
}
|
||||
}),
|
||||
sort_direction: Property.StaticDropdown({
|
||||
displayName: 'Sort Direction',
|
||||
required: false,
|
||||
defaultValue: 'DESC',
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'Newest first', value: 'DESC' },
|
||||
{ label: 'Oldest first', value: 'ASC' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
limit: Property.Number({
|
||||
displayName: 'Limit',
|
||||
description: 'Maximum number of entities to retrieve (0 = all entities)',
|
||||
required: true,
|
||||
defaultValue: 50,
|
||||
}),
|
||||
output_options: Property.DynamicProperties({
|
||||
displayName: 'Output Options',
|
||||
required: false,
|
||||
refreshers: ['entity_type'],
|
||||
auth: drupalAuth,
|
||||
props: async (propsValue) => {
|
||||
const entityInfo = propsValue['entity_type'] as any;
|
||||
if (!entityInfo) return {};
|
||||
|
||||
// Only show minimal output option for nodes (they can have many fields)
|
||||
if (entityInfo.entity_type === 'node') {
|
||||
return {
|
||||
minimal_output: Property.Checkbox({
|
||||
displayName: 'Minimal Output',
|
||||
description: 'Return only basic fields (UUID, title, status, dates) instead of all entity data',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
})
|
||||
} as any;
|
||||
}
|
||||
|
||||
return {} as any;
|
||||
}
|
||||
}),
|
||||
},
|
||||
async run({ auth, propsValue }) {
|
||||
const entityInfo = propsValue.entity_type as any;
|
||||
|
||||
const filters: Record<string, any> = {};
|
||||
|
||||
if (propsValue.published_status === 'published') {
|
||||
filters['status'] = '1';
|
||||
} else if (propsValue.published_status === 'unpublished') {
|
||||
filters['status'] = '0';
|
||||
}
|
||||
|
||||
let fields: string[] | undefined;
|
||||
const outputOptions = propsValue.output_options as any;
|
||||
|
||||
if (entityInfo.entity_type === 'node' && outputOptions?.minimal_output) {
|
||||
fields = ['id', 'title', 'status', 'created', 'changed', 'drupal_internal__nid', 'path'];
|
||||
} else if (entityInfo.entity_type === 'taxonomy_term') {
|
||||
fields = ['id', 'name', 'changed', 'created', 'path', 'drupal_internal__tid', 'status'];
|
||||
} else if (entityInfo.entity_type === 'user') {
|
||||
fields = ['id', 'name', 'mail', 'created', 'changed', 'status'];
|
||||
}
|
||||
|
||||
const sortField = (propsValue.sort_by as any)?.sort_field;
|
||||
|
||||
let entities = await drupal.listEntities(
|
||||
auth,
|
||||
entityInfo.entity_type,
|
||||
entityInfo.bundle,
|
||||
{
|
||||
filters,
|
||||
sort: sortField,
|
||||
sortDirection: propsValue.sort_direction,
|
||||
fields,
|
||||
limit: propsValue.limit,
|
||||
}
|
||||
);
|
||||
|
||||
// Remove type field for cleaner output
|
||||
const removeType = (entity: any) => {
|
||||
const { type, ...entityWithoutType } = entity;
|
||||
return entityWithoutType;
|
||||
};
|
||||
|
||||
entities = Array.isArray(entities)
|
||||
? entities.map(removeType)
|
||||
: removeType(entities);
|
||||
|
||||
return {
|
||||
entities: Array.isArray(entities) ? entities : [entities],
|
||||
count: Array.isArray(entities) ? entities.length : 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
createAction,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
httpClient,
|
||||
HttpMethod,
|
||||
HttpRequest,
|
||||
} from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
type DrupalAuthType = PiecePropValueSchema<typeof drupalAuth>;
|
||||
|
||||
export const drupalCallServiceAction = createAction({
|
||||
auth: drupalAuth,
|
||||
name: 'drupal-call-service',
|
||||
displayName: 'Call Service',
|
||||
description: 'Call a service on the Drupal site',
|
||||
props: {
|
||||
service: Property.Dropdown({
|
||||
displayName: 'Service',
|
||||
description: 'The service to call.',
|
||||
required: true,
|
||||
refreshers: [],
|
||||
auth: drupalAuth,
|
||||
options: async ({ auth }) => {
|
||||
if (!auth) {
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Please authenticate first.',
|
||||
};
|
||||
}
|
||||
const { website_url, username, password } = auth.props;
|
||||
|
||||
|
||||
try {
|
||||
const response = await httpClient.sendRequest<DrupalService[]>({
|
||||
method: HttpMethod.GET,
|
||||
url: website_url + `/orchestration/services`,
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
});
|
||||
console.debug('Service response', response);
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
disabled: false,
|
||||
options: response.body.map((service) => {
|
||||
return {
|
||||
label: service.label,
|
||||
description: service.description,
|
||||
value: service,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.debug('Service error', e);
|
||||
}
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Error processing services',
|
||||
};
|
||||
},
|
||||
}),
|
||||
config: Property.DynamicProperties({
|
||||
displayName: 'Service configuration',
|
||||
refreshers: ['service'],
|
||||
required: true,
|
||||
auth: drupalAuth,
|
||||
props: async ({ service }) => {
|
||||
console.debug('Service config input', service);
|
||||
const fields: Record<string, any> = {};
|
||||
const items = (service as {config: DrupalServiceConfig[]}).config;
|
||||
items.forEach((config: any) => {
|
||||
if (config.type === 'boolean') {
|
||||
fields[config.key] = Property.Checkbox({
|
||||
displayName: config.label,
|
||||
description: config.description,
|
||||
required: config.required,
|
||||
defaultValue: config.default_value,
|
||||
});
|
||||
} else if (config.type === 'integer' || config.type === 'float') {
|
||||
fields[config.key] = Property.Number({
|
||||
displayName: config.label,
|
||||
description: config.description,
|
||||
required: config.required,
|
||||
defaultValue: config.default_value,
|
||||
});
|
||||
} else if (config.type === 'timestamp' || config.type === 'datetime_iso8601') {
|
||||
fields[config.key] = Property.DateTime({
|
||||
displayName: config.label,
|
||||
description: config.description,
|
||||
required: config.required,
|
||||
defaultValue: config.default_value,
|
||||
});
|
||||
} else if (config.options.length > 0) {
|
||||
fields[config.key] = Property.StaticDropdown({
|
||||
displayName: config.label,
|
||||
description: config.description,
|
||||
required: config.required,
|
||||
defaultValue: config.default_value,
|
||||
options: {
|
||||
options: config.options.map((option: any) => ({
|
||||
label: option.name,
|
||||
value: option.key,
|
||||
}))},
|
||||
});
|
||||
} else {
|
||||
|
||||
fields[config.key] = Property.ShortText({
|
||||
displayName: config.label,
|
||||
description: config.description,
|
||||
required: config.required,
|
||||
defaultValue: config.default_value,
|
||||
});
|
||||
}
|
||||
});
|
||||
console.debug('Field for this service', fields);
|
||||
return fields;
|
||||
},
|
||||
}),
|
||||
},
|
||||
async run({ auth, propsValue }) {
|
||||
const { website_url, username, password } = auth.props;
|
||||
const request: HttpRequest = {
|
||||
method: HttpMethod.POST,
|
||||
url: website_url + `/orchestration/service/execute`,
|
||||
body: {
|
||||
id: propsValue.service.id,
|
||||
config: propsValue.config,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await httpClient.sendRequest<DrupalService>(request);
|
||||
console.debug('Service call completed', result);
|
||||
|
||||
if (result.status === 200 || result.status === 202) {
|
||||
return result.body;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
interface DrupalService {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
config: DrupalServiceConfig[];
|
||||
}
|
||||
|
||||
interface DrupalServiceConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
type: string;
|
||||
default_value: string;
|
||||
options: string[];
|
||||
editable: boolean;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
createAction,
|
||||
DynamicPropsValue,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { drupal } from '../common/jsonapi';
|
||||
import {
|
||||
fetchEntityTypesForEditing,
|
||||
buildFieldProperties
|
||||
} from '../common/drupal-entities';
|
||||
|
||||
type DrupalAuthType = PiecePropValueSchema<typeof drupalAuth>;
|
||||
|
||||
export const drupalUpdateEntityAction = createAction({
|
||||
auth: drupalAuth,
|
||||
name: 'drupal-update-entity',
|
||||
displayName: 'Update Entity',
|
||||
description: 'Update an existing entity in Drupal with smart field discovery and validation',
|
||||
props: {
|
||||
entity_type: Property.Dropdown({
|
||||
auth: drupalAuth,
|
||||
displayName: 'Entity Type',
|
||||
description: 'Select the entity type and bundle',
|
||||
required: true,
|
||||
refreshers: [],
|
||||
options: async ({ auth }) => fetchEntityTypesForEditing(auth),
|
||||
}),
|
||||
entity_uuid: Property.ShortText({
|
||||
displayName: 'Entity UUID',
|
||||
description: 'The UUID of the entity to update',
|
||||
required: true,
|
||||
}),
|
||||
entity_fields: Property.DynamicProperties({
|
||||
auth: drupalAuth,
|
||||
displayName: 'Entity Fields',
|
||||
description: 'Update the values for the entity fields (only provide values for fields you want to change)',
|
||||
required: false,
|
||||
refreshers: ['entity_type'],
|
||||
props: async (propsValue) => {
|
||||
const { auth, entity_type } = propsValue;
|
||||
if (!auth) {
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Please configure authentication first',
|
||||
};
|
||||
}
|
||||
return buildFieldProperties(auth, entity_type, false);
|
||||
}
|
||||
}),
|
||||
},
|
||||
async run({ auth, propsValue }) {
|
||||
const entityInfo = propsValue['entity_type'] as any;
|
||||
|
||||
const fieldsData = propsValue['entity_fields'] as any;
|
||||
|
||||
// Extract field values, handling text fields with format correctly
|
||||
const fieldsToUpdate: Record<string, any> = {};
|
||||
const processedFormatFields = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(fieldsData)) {
|
||||
// Skip empty values and already processed format fields
|
||||
if (value === undefined || value === null || value === '' || processedFormatFields.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle format fields (they should be combined with their text field)
|
||||
if (key.endsWith('_format')) {
|
||||
const textFieldName = key.replace('_format', '');
|
||||
const textValue = fieldsData[textFieldName];
|
||||
|
||||
if (textValue) {
|
||||
fieldsToUpdate[textFieldName] = {
|
||||
value: textValue,
|
||||
format: value
|
||||
};
|
||||
processedFormatFields.add(textFieldName);
|
||||
}
|
||||
processedFormatFields.add(key);
|
||||
}
|
||||
// Handle text fields (check if they have a format)
|
||||
else {
|
||||
const formatKey = `${key}_format`;
|
||||
const formatValue = fieldsData[formatKey];
|
||||
|
||||
if (formatValue && formatValue !== undefined && formatValue !== null && formatValue !== '') {
|
||||
fieldsToUpdate[key] = {
|
||||
value: value,
|
||||
format: formatValue
|
||||
};
|
||||
processedFormatFields.add(formatKey);
|
||||
} else {
|
||||
fieldsToUpdate[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(fieldsToUpdate).length === 0) {
|
||||
throw new Error('No fields provided to update');
|
||||
}
|
||||
|
||||
return await drupal.updateEntity(
|
||||
auth,
|
||||
entityInfo.entity_type,
|
||||
entityInfo.bundle,
|
||||
propsValue['entity_uuid'],
|
||||
fieldsToUpdate
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import {
|
||||
HttpMethod,
|
||||
} from '@activepieces/pieces-common';
|
||||
import {
|
||||
DynamicPropsValue,
|
||||
Property,
|
||||
AppConnectionValueForAuthProperty,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { drupalAuth } from '../../';
|
||||
import { DrupalAuthType, makeJsonApiRequest } from './jsonapi';
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// ENTITY TYPE DISCOVERY
|
||||
// Functions for discovering what entity types are available in Drupal
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Discovers available entity types from Drupal's JSON:API endpoint
|
||||
*
|
||||
* This function queries the main JSON:API endpoint to see what entity types
|
||||
* and bundles are available, then filters them to only show content entities
|
||||
* that users would typically want to work with (not config entities).
|
||||
*
|
||||
* @param auth - Drupal authentication credentials
|
||||
* @param context - Whether entities will be used for 'reading' or 'editing'
|
||||
* @returns Dropdown options for entity type selection
|
||||
*/
|
||||
async function fetchEntityTypes(auth: DrupalAuthType, context: 'reading' | 'editing') {
|
||||
if (!auth || !auth.props.website_url) {
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Please configure authentication first',
|
||||
};
|
||||
}
|
||||
|
||||
// Get the list of entity types that are allowed for this context
|
||||
const type = context === 'editing'
|
||||
? 'form'
|
||||
: 'view';
|
||||
const response = await makeJsonApiRequest(auth, `${auth.props.website_url}/jsonapi/entity_${type}_display/entity_${type}_display`, HttpMethod.GET);
|
||||
const data = (response.body as any).data || [];
|
||||
const allowedEntityTypes: string[] = [];
|
||||
data.forEach((entityType: any) => {
|
||||
allowedEntityTypes.push(entityType.attributes.targetEntityType + '--' + entityType.attributes.bundle);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await makeJsonApiRequest(auth, `${auth.props.website_url}/jsonapi`, HttpMethod.GET);
|
||||
|
||||
if (response.status === 200) {
|
||||
const entityTypes: Array<{label: string; value: any}> = [];
|
||||
const data = response.body as any;
|
||||
|
||||
if (data.links) {
|
||||
for (const [key, value] of Object.entries(data.links)) {
|
||||
if (key !== 'self' && typeof value === 'object' && (value as any).href) {
|
||||
const parts = key.split('--');
|
||||
if (parts.length === 2) {
|
||||
const [entityType, bundle] = parts;
|
||||
|
||||
if (allowedEntityTypes.includes(key)) {
|
||||
const bundleName = bundle.charAt(0).toUpperCase() + bundle.slice(1).replace(/_/g, ' ');
|
||||
const entityTypeName = entityType.charAt(0).toUpperCase() + entityType.slice(1).replace(/_/g, ' ');
|
||||
const label = `${bundleName} (${entityTypeName})`;
|
||||
|
||||
entityTypes.push({
|
||||
label,
|
||||
value: {
|
||||
id: key,
|
||||
entity_type: entityType,
|
||||
bundle,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
options: entityTypes.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch entity types', e);
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Error loading entity types',
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchEntityTypesForReading(auth?: DrupalAuthType) {
|
||||
if (!auth) {
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Please configure authentication first',
|
||||
};
|
||||
}
|
||||
return await fetchEntityTypes(auth, 'reading');
|
||||
}
|
||||
|
||||
export async function fetchEntityTypesForEditing(auth?: DrupalAuthType) {
|
||||
if (!auth) {
|
||||
return {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Please configure authentication first',
|
||||
};
|
||||
}
|
||||
return await fetchEntityTypes(auth, 'editing');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FORM DISPLAY DISCOVERY
|
||||
// Functions for discovering how Drupal displays forms for entity editing
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetches the form display configuration for an entity bundle
|
||||
*
|
||||
* In Drupal, administrators configure which fields appear on edit forms,
|
||||
* their order, and how they're displayed. This function retrieves that
|
||||
* configuration so we can show the same fields in the same order.
|
||||
*
|
||||
* Without this, we might show fields that admins have intentionally hidden
|
||||
* or fields that are read-only and shouldn't be edited.
|
||||
*
|
||||
* @param auth - Drupal authentication credentials
|
||||
* @param entityType - The entity type (e.g., 'node', 'user')
|
||||
* @param bundle - The bundle (e.g., 'article', 'page')
|
||||
* @returns Form display configuration object
|
||||
*/
|
||||
export async function fetchEntityFormDisplay(auth: DrupalAuthType, entityType: string, bundle: string) {
|
||||
try {
|
||||
const formDisplayId = `${entityType}.${bundle}.default`;
|
||||
const response = await makeJsonApiRequest(
|
||||
auth,
|
||||
`${auth.props.website_url}/jsonapi/entity_form_display/entity_form_display?filter[drupal_internal__id]=${encodeURIComponent(formDisplayId)}`,
|
||||
HttpMethod.GET
|
||||
);
|
||||
|
||||
if (response.status === 200 && response.body) {
|
||||
const data = (response.body as any).data;
|
||||
if (data && data.length > 0) {
|
||||
return data[0].attributes.content || {};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch form display', e);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches available text formats for rich text fields
|
||||
*
|
||||
* Drupal allows different text formats (like 'basic_html', 'full_html')
|
||||
* for rich text fields. This function gets the available formats so users
|
||||
* can choose how their content should be processed.
|
||||
*/
|
||||
export async function fetchTextFormats(auth: DrupalAuthType) {
|
||||
try {
|
||||
const response = await makeJsonApiRequest(
|
||||
auth,
|
||||
`${auth.props.website_url}/jsonapi/filter_format/filter_format`,
|
||||
HttpMethod.GET
|
||||
);
|
||||
|
||||
if (response.status === 200 && response.body) {
|
||||
const formats = (response.body as any).data || [];
|
||||
return formats.reduce((acc: Record<string, string>, format: any) => {
|
||||
const formatId = format.attributes.drupal_internal__format;
|
||||
const formatName = format.attributes.name;
|
||||
if (formatId && formatName) {
|
||||
acc[formatId] = formatName;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch text formats', e);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FIELD CONFIGURATION DISCOVERY
|
||||
// Functions for discovering field metadata and configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetches detailed field configuration including labels and requirements
|
||||
*
|
||||
* This gets the actual field definitions including human-readable labels,
|
||||
* whether fields are required, field types, etc. This metadata is used
|
||||
* to create appropriate form inputs with proper validation.
|
||||
*
|
||||
* @param auth - Drupal authentication credentials
|
||||
* @param entityType - The entity type (e.g., 'node', 'user')
|
||||
* @param bundle - The bundle (e.g., 'article', 'page')
|
||||
* @returns Object mapping field names to their configuration
|
||||
*/
|
||||
export async function fetchEntityFieldConfig(auth: DrupalAuthType, entityType: string, bundle: string) {
|
||||
try {
|
||||
const response = await makeJsonApiRequest(
|
||||
auth,
|
||||
`${auth.props.website_url}/jsonapi/field_config/field_config?filter[entity_type]=${entityType}&filter[bundle]=${bundle}`,
|
||||
HttpMethod.GET
|
||||
);
|
||||
|
||||
if (response.status === 200 && response.body) {
|
||||
const fields = (response.body as any).data || [];
|
||||
const fieldConfig: Record<string, any> = {};
|
||||
|
||||
fields.forEach((field: any) => {
|
||||
const fieldName = field.attributes.field_name;
|
||||
fieldConfig[fieldName] = {
|
||||
label: field.attributes.label,
|
||||
required: field.attributes.required,
|
||||
fieldType: field.attributes.field_type,
|
||||
};
|
||||
});
|
||||
|
||||
return fieldConfig;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch field config', e);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the entity type and bundle are supported by the workflow
|
||||
*
|
||||
* This gets the actual workflow configuration on the site and determines if the
|
||||
* given entity type and bundle is supported by the workflow.
|
||||
*
|
||||
* @param auth - Drupal authentication credentials
|
||||
* @param entityType - The entity type (e.g., 'node', 'user')
|
||||
* @param bundle - The bundle (e.g., 'article', 'page')
|
||||
* @returns True if the entity type and bundle are supported by the workflow,
|
||||
* false otherwise
|
||||
*/
|
||||
export async function isEntitySupportingWorkflow(auth: DrupalAuthType, entityType: string, bundle: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await makeJsonApiRequest(
|
||||
auth,
|
||||
`${auth.props. website_url}/jsonapi/workflow/workflow`,
|
||||
HttpMethod.GET
|
||||
);
|
||||
|
||||
if (response.status === 200 && response.body) {
|
||||
const workflows = (response.body as any).data || [];
|
||||
let found = false;
|
||||
workflows.forEach((workflow: any) => {
|
||||
const attrs = workflow?.attributes ?? {};
|
||||
const typeSettings = attrs?.type_settings ?? {};
|
||||
const entityTypes = typeSettings?.entity_types ?? {};
|
||||
|
||||
if (!entityTypes) {
|
||||
return;
|
||||
}
|
||||
if (entityTypes[entityType].includes(bundle)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore this as the workflow may not be supported or the endpoint missing.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a field type can be edited with simple form inputs
|
||||
*
|
||||
* Some Drupal field types are too complex for simple text/checkbox inputs
|
||||
* (like entity references, file uploads). This function determines which
|
||||
* field types we can reasonably handle in a workflow interface.
|
||||
*/
|
||||
export function isEditableFieldType(fieldType: string): boolean {
|
||||
const editableTypes = [
|
||||
'string', 'string_long', 'text', 'text_long', 'text_with_summary',
|
||||
'integer', 'decimal', 'float', 'boolean', 'email', 'telephone', 'uri'
|
||||
];
|
||||
return editableTypes.includes(fieldType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets human-readable labels for Drupal base fields
|
||||
*
|
||||
* TODO: This should be fetched from the form display instead of hardcoded.
|
||||
* Base fields like 'title', 'status' have standard labels, but these could
|
||||
* be customized by site administrators. We should get the actual labels
|
||||
* from the form display configuration.
|
||||
*
|
||||
* @param fieldName - The machine name of the field
|
||||
* @returns Human-readable label for the field
|
||||
*/
|
||||
export function getBaseFieldLabel(fieldName: string): string {
|
||||
const baseFieldLabels: Record<string, string> = {
|
||||
'title': 'Title',
|
||||
'status': 'Published',
|
||||
'created': 'Authored on',
|
||||
'changed': 'Changed',
|
||||
'promote': 'Promoted to front page',
|
||||
'sticky': 'Sticky at top of lists',
|
||||
'name': 'Name',
|
||||
'mail': 'Email address',
|
||||
};
|
||||
|
||||
return baseFieldLabels[fieldName] || fieldName;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FIELD PROCESSING & FORM GENERATION
|
||||
// Functions that combine the above data to generate form properties
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extracts editable fields from form display configuration
|
||||
*
|
||||
* This combines form display configuration (what fields to show) with
|
||||
* field configuration (labels, types, requirements) to create a list
|
||||
* of fields that should be editable in the workflow interface.
|
||||
*
|
||||
* @param auth - Drupal authentication credentials
|
||||
* @param entityType - The entity type (e.g., 'node', 'user')
|
||||
* @param bundle - The bundle (e.g., 'article', 'page')
|
||||
* @param formDisplayContent - Form display configuration from fetchEntityFormDisplay
|
||||
* @returns Array of field objects with name, type, label, required, weight
|
||||
*/
|
||||
export async function getEditableFieldsWithLabels(
|
||||
auth: DrupalAuthType,
|
||||
entityType: string,
|
||||
bundle: string,
|
||||
formDisplayContent: Record<string, any>
|
||||
) {
|
||||
const fieldConfig = await fetchEntityFieldConfig(auth, entityType, bundle);
|
||||
const fields: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
weight: number;
|
||||
}> = [];
|
||||
|
||||
const baseFields = ['title', 'name'];
|
||||
if (!await isEntitySupportingWorkflow(auth, entityType, bundle)) {
|
||||
baseFields.push('status');
|
||||
}
|
||||
|
||||
for (const [fieldName, config] of Object.entries(formDisplayContent)) {
|
||||
if (config && typeof config === 'object' && config.type) {
|
||||
const configInfo = fieldConfig[fieldName];
|
||||
|
||||
if (configInfo) {
|
||||
// Custom field with configuration
|
||||
if (isEditableFieldType(configInfo.fieldType)) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
type: configInfo.fieldType,
|
||||
label: configInfo.label,
|
||||
required: configInfo.required,
|
||||
weight: config.weight || 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Base field - check if it's editable
|
||||
if (baseFields.includes(fieldName)) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
type: fieldName === 'status' ? 'boolean' : 'string',
|
||||
label: getBaseFieldLabel(fieldName),
|
||||
required: ['title', 'name'].includes(fieldName),
|
||||
weight: config.weight || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by weight (form display order), then by label
|
||||
return fields.sort((a, b) => {
|
||||
if (a.weight !== b.weight) return a.weight - b.weight;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Activepieces Property objects for dynamic form generation
|
||||
*
|
||||
* This is the main function that combines all the field discovery and
|
||||
* configuration to create the actual form properties that users will
|
||||
* see in the Activepieces interface.
|
||||
*
|
||||
* @param auth - Drupal authentication credentials
|
||||
* @param entityType - Selected entity type from dropdown
|
||||
* @param isCreateAction - Whether this is for creating (true) or updating (false)
|
||||
* @returns Dynamic properties object for Activepieces form
|
||||
*/
|
||||
export async function buildFieldProperties(
|
||||
auth: DrupalAuthType,
|
||||
entityType: any,
|
||||
isCreateAction = false
|
||||
): Promise<DynamicPropsValue> {
|
||||
const properties: DynamicPropsValue = {};
|
||||
|
||||
if (!entityType) {
|
||||
return properties;
|
||||
}
|
||||
|
||||
try {
|
||||
const formDisplay = await fetchEntityFormDisplay(auth, entityType.entity_type, entityType.bundle);
|
||||
const textFormats = await fetchTextFormats(auth);
|
||||
const availableFields = await getEditableFieldsWithLabels(
|
||||
auth,
|
||||
entityType.entity_type,
|
||||
entityType.bundle,
|
||||
formDisplay
|
||||
);
|
||||
|
||||
if (availableFields.length === 0) {
|
||||
properties['no_fields'] = Property.MarkDown({
|
||||
value: 'No editable fields found for this entity type.'
|
||||
});
|
||||
return properties;
|
||||
}
|
||||
|
||||
// Generate properties for all editable fields
|
||||
for (const field of availableFields) {
|
||||
const displayName = field.label;
|
||||
const description = undefined;
|
||||
const isRequired = field.required && isCreateAction;
|
||||
|
||||
if (field.type === 'text_with_summary' || field.type === 'text_long') {
|
||||
properties[field.name] = Property.LongText({
|
||||
displayName,
|
||||
description,
|
||||
required: isRequired,
|
||||
});
|
||||
|
||||
// Add text format selection if formats are available
|
||||
if (Object.keys(textFormats).length > 0) {
|
||||
properties[`${field.name}_format`] = Property.StaticDropdown({
|
||||
displayName: `${displayName} Format`,
|
||||
required: false,
|
||||
options: {
|
||||
options: Object.entries(textFormats).map(([key, name]) => ({
|
||||
label: String(name),
|
||||
value: key,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (field.type === 'boolean') {
|
||||
properties[field.name] = Property.Checkbox({
|
||||
displayName,
|
||||
description,
|
||||
required: isRequired,
|
||||
});
|
||||
} else {
|
||||
// Default to text input for most field types
|
||||
properties[field.name] = Property.ShortText({
|
||||
displayName,
|
||||
description,
|
||||
required: isRequired,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to generate field properties', e);
|
||||
properties['error'] = Property.MarkDown({
|
||||
value: 'Failed to load fields. Please check your authentication and entity type selection.'
|
||||
});
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* JSON:API Client - Two-Layer Architecture
|
||||
*
|
||||
* This module implements a two-layer approach for JSON:API operations:
|
||||
*
|
||||
* Layer 1 (Generic): Pure JSON:API operations that work with any JSON:API server
|
||||
* - jsonApi.get(), jsonApi.list(), jsonApi.create(), etc.
|
||||
* - Could be extracted to @activepieces/pieces-common for reuse across pieces
|
||||
* - Handles raw JSON:API requests, URLs, and response formats
|
||||
*
|
||||
* Layer 2 (Drupal-specific): Convenience functions for Drupal entity operations
|
||||
* - drupal.getEntity(), drupal.listEntities(), drupal.createEntity(), etc.
|
||||
* - Abstracts away entity types, bundles, and URL construction
|
||||
* - Provides clean developer experience with simple objects instead of JSON:API format
|
||||
*/
|
||||
|
||||
import {
|
||||
HttpMethod,
|
||||
httpClient,
|
||||
} from '@activepieces/pieces-common';
|
||||
import {
|
||||
AppConnectionValueForAuthProperty,
|
||||
PiecePropValueSchema,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { drupalAuth } from '../../';
|
||||
|
||||
export type DrupalAuthType = AppConnectionValueForAuthProperty<typeof drupalAuth>;
|
||||
|
||||
export interface JsonApiResource {
|
||||
type: string;
|
||||
id?: string;
|
||||
attributes: Record<string, any>;
|
||||
relationships?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface JsonApiResponse {
|
||||
data: JsonApiResource | JsonApiResource[];
|
||||
included?: JsonApiResource[];
|
||||
links?: Record<string, string | { href: string }>;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a JSON:API request with proper authentication
|
||||
*/
|
||||
export async function makeJsonApiRequest<T = JsonApiResponse>(
|
||||
auth: DrupalAuthType,
|
||||
endpoint: string,
|
||||
method: HttpMethod = HttpMethod.GET,
|
||||
body?: any
|
||||
) {
|
||||
const { website_url, username, password } = auth.props;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/vnd.api+json',
|
||||
};
|
||||
|
||||
if (username && password) {
|
||||
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${basicAuth}`;
|
||||
}
|
||||
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/vnd.api+json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.sendRequest({
|
||||
method,
|
||||
url: endpoint,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Sanitize response body if it's a string containing JSON
|
||||
if (response.body && typeof response.body === 'string') {
|
||||
try {
|
||||
// Remove invalid control characters that violate JSON specification (RFC 8259 Section 7)
|
||||
// Workaround for Drupal bug: https://www.drupal.org/project/drupal/issues/3549107
|
||||
// TODO: Remove this when Drupal issue is fixed
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanedBody = response.body.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
response.body = JSON.parse(cleanedBody);
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse JSON response, returning raw body:', parseError);
|
||||
// Return response as-is if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('JSON:API request failed:', { endpoint, method, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds JSON:API URL for entity operations
|
||||
*/
|
||||
export function getJsonApiUrl(
|
||||
baseUrl: string,
|
||||
entityType: string,
|
||||
bundle: string,
|
||||
uuid?: string
|
||||
): string {
|
||||
const cleanBaseUrl = baseUrl.replace(/\/+$/, '');
|
||||
const resourceType = `${entityType}--${bundle}`;
|
||||
|
||||
let url = `${cleanBaseUrl}/jsonapi/${entityType}/${resourceType}`;
|
||||
|
||||
if (uuid) {
|
||||
url += `/${uuid}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts simple object to JSON:API format
|
||||
*/
|
||||
export function toJsonApiFormat(
|
||||
entityType: string,
|
||||
bundle: string,
|
||||
data: Record<string, any>,
|
||||
id?: string
|
||||
): JsonApiResponse {
|
||||
const resourceType = `${entityType}--${bundle}`;
|
||||
|
||||
const attributes: Record<string, any> = {};
|
||||
const relationships: Record<string, any> = {};
|
||||
|
||||
const isRelationship = (value: any) => value && typeof value === 'object' && 'data' in value;
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (isRelationship(value)) {
|
||||
relationships[key] = value;
|
||||
} else {
|
||||
attributes[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const resource: JsonApiResource = {
|
||||
type: resourceType,
|
||||
attributes,
|
||||
};
|
||||
|
||||
if (id) {
|
||||
resource.id = id;
|
||||
}
|
||||
|
||||
if (Object.keys(relationships).length > 0) {
|
||||
resource.relationships = relationships;
|
||||
}
|
||||
|
||||
return { data: resource };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts JSON:API response to simple object format
|
||||
*/
|
||||
export function fromJsonApiFormat(response: JsonApiResponse): any | any[] {
|
||||
if (!response.data) return null;
|
||||
|
||||
try {
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data
|
||||
.filter(resource => resource != null)
|
||||
.map(convertJsonApiResource);
|
||||
} else {
|
||||
return convertJsonApiResource(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Return empty array instead of throwing, so pagination can continue
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function convertJsonApiResource(resource: JsonApiResource) {
|
||||
if (!resource || typeof resource !== 'object') {
|
||||
throw new Error('Invalid resource: resource is not an object');
|
||||
}
|
||||
|
||||
if (!resource.type) {
|
||||
throw new Error('Invalid resource: missing required "type" field');
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
};
|
||||
|
||||
// Safely copy attributes
|
||||
if (resource.attributes && typeof resource.attributes === 'object') {
|
||||
try {
|
||||
Object.assign(result, resource.attributes);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to copy attributes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Safely copy relationships
|
||||
if (resource.relationships && typeof resource.relationships === 'object') {
|
||||
try {
|
||||
for (const [key, value] of Object.entries(resource.relationships)) {
|
||||
result[key] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to copy relationships: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds query parameters for JSON:API requests
|
||||
*/
|
||||
function buildQueryParams(options: {
|
||||
filters?: Record<string, any>;
|
||||
sort?: string;
|
||||
sortDirection?: string;
|
||||
fields?: string[];
|
||||
resourceType?: string;
|
||||
limit?: number;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.filters) {
|
||||
for (const [key, value] of Object.entries(options.filters)) {
|
||||
params.append(`filter[${key}]`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sort) {
|
||||
const sortParam = options.sortDirection === 'ASC'
|
||||
? options.sort
|
||||
: `-${options.sort}`;
|
||||
params.append('sort', sortParam);
|
||||
}
|
||||
|
||||
if (options.fields && options.resourceType) {
|
||||
const fieldsParam = options.fields.join(',');
|
||||
params.append(`fields[${options.resourceType}]`, fieldsParam);
|
||||
}
|
||||
|
||||
if (options.limit && options.limit > 0) {
|
||||
params.append('page[limit]', String(options.limit));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LAYER 1: Generic JSON:API Operations
|
||||
//
|
||||
// These functions work with any JSON:API server and handle the raw JSON:API
|
||||
// specification. They could be extracted to @activepieces/pieces-common for
|
||||
// reuse across different pieces (Rails API, Laravel API, etc.)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generic JSON:API client for any JSON:API compliant server
|
||||
*/
|
||||
export const jsonApi = {
|
||||
/**
|
||||
* Fetch a single resource by full JSON:API path
|
||||
* @example jsonApi.get(auth, '/jsonapi/node/node--article/12345')
|
||||
*/
|
||||
async get(auth: DrupalAuthType, resourcePath: string) {
|
||||
const url = `${auth.props.website_url.replace(/\/+$/, '')}${resourcePath}`;
|
||||
const result = await makeJsonApiRequest(auth, url, HttpMethod.GET);
|
||||
|
||||
if (result.status === 200) {
|
||||
return fromJsonApiFormat(result.body as JsonApiResponse);
|
||||
} else if (result.status === 404) {
|
||||
throw new Error(`Resource not found: ${resourcePath}`);
|
||||
} else if (result.status === 403) {
|
||||
throw new Error(`Access denied: ${resourcePath}`);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to get resource: ${result.status}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a collection of resources with optional query parameters
|
||||
* Follows pagination links if present to retrieve all requested data
|
||||
* @example jsonApi.list(auth, '/jsonapi/node/node--article', { sort: 'created', filters: { status: '1' } })
|
||||
*/
|
||||
async list(auth: DrupalAuthType, collectionPath: string, options?: {
|
||||
filters?: Record<string, any>;
|
||||
sort?: string;
|
||||
sortDirection?: string;
|
||||
fields?: string[];
|
||||
resourceType?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const allEntities: any[] = [];
|
||||
const query = options ? buildQueryParams(options) : '';
|
||||
let url: string | null = `${auth.props.website_url.replace(/\/+$/, '')}${collectionPath}${query}`;
|
||||
|
||||
do {
|
||||
const result = await makeJsonApiRequest(auth, url, HttpMethod.GET);
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error(`Failed to list resources: ${result.status}`);
|
||||
}
|
||||
|
||||
if (!result.body) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse JSON if response body is a string
|
||||
let parsedBody: JsonApiResponse;
|
||||
if (typeof result.body === 'string') {
|
||||
try {
|
||||
parsedBody = JSON.parse(result.body);
|
||||
} catch (parseError) {
|
||||
console.warn('Skipping page due to corrupted data in Drupal database:', parseError);
|
||||
url = null; // Stop pagination
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
parsedBody = result.body as JsonApiResponse;
|
||||
}
|
||||
|
||||
const response = parsedBody;
|
||||
const entities = fromJsonApiFormat(response);
|
||||
|
||||
// Add entities from this page
|
||||
if (Array.isArray(entities)) {
|
||||
allEntities.push(...entities);
|
||||
} else if (entities) {
|
||||
allEntities.push(entities);
|
||||
}
|
||||
|
||||
// Continue to next page if it exists
|
||||
const nextLink = response.links?.['next'];
|
||||
url = typeof nextLink === 'string' ? nextLink : nextLink?.href || null;
|
||||
|
||||
} while (url);
|
||||
|
||||
return allEntities;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new resource with JSON:API formatted data
|
||||
* @example jsonApi.create(auth, '/jsonapi/node/node--article', jsonApiFormattedData)
|
||||
*/
|
||||
async create(auth: DrupalAuthType, collectionPath: string, jsonApiData: JsonApiResponse) {
|
||||
const url = `${auth.props.website_url.replace(/\/+$/, '')}${collectionPath}`;
|
||||
const result = await makeJsonApiRequest(auth, url, HttpMethod.POST, jsonApiData);
|
||||
|
||||
if (result.status === 201 || result.status === 200) {
|
||||
return fromJsonApiFormat(result.body as JsonApiResponse);
|
||||
} else if (result.status === 422) {
|
||||
const errors = (result.body as any).errors || [];
|
||||
const errorMsg = errors.map((e: any) => e.detail || e.title).join(', ');
|
||||
throw new Error(`Validation failed: ${errorMsg}`);
|
||||
} else if (result.status === 403) {
|
||||
throw new Error('Permission denied to create resource');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to create resource: ${result.status}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a resource with JSON:API formatted data
|
||||
* @example jsonApi.update(auth, '/jsonapi/node/node--article/12345', jsonApiFormattedData)
|
||||
*/
|
||||
async update(auth: DrupalAuthType, resourcePath: string, jsonApiData: JsonApiResponse) {
|
||||
const url = `${auth.props.website_url.replace(/\/+$/, '')}${resourcePath}`;
|
||||
const result = await makeJsonApiRequest(auth, url, HttpMethod.PATCH, jsonApiData);
|
||||
|
||||
if (result.status === 200) {
|
||||
return fromJsonApiFormat(result.body as JsonApiResponse);
|
||||
} else if (result.status === 422) {
|
||||
const errors = (result.body as any).errors || [];
|
||||
const errorMsg = errors.map((e: any) => e.detail || e.title).join(', ');
|
||||
throw new Error(`Validation failed: ${errorMsg}`);
|
||||
} else if (result.status === 404) {
|
||||
throw new Error(`Resource not found: ${resourcePath}`);
|
||||
} else if (result.status === 403) {
|
||||
throw new Error('Permission denied to update resource');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to update resource: ${result.status}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a resource
|
||||
* @example jsonApi.delete(auth, '/jsonapi/node/node--article/12345')
|
||||
*/
|
||||
async delete(auth: DrupalAuthType, resourcePath: string) {
|
||||
const url = `${auth.props.website_url.replace(/\/+$/, '')}${resourcePath}`;
|
||||
const result = await makeJsonApiRequest(auth, url, HttpMethod.DELETE);
|
||||
|
||||
if (result.status === 204 || result.status === 200) {
|
||||
return { success: true, message: `Deleted resource: ${resourcePath}` };
|
||||
} else if (result.status === 404) {
|
||||
throw new Error(`Resource not found: ${resourcePath}`);
|
||||
} else if (result.status === 403) {
|
||||
throw new Error('Permission denied to delete resource');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to delete resource: ${result.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// LAYER 2: Drupal-Specific Operations
|
||||
//
|
||||
// These functions provide a clean developer experience by abstracting away
|
||||
// Drupal-specific concepts like entity types, bundles, and JSON:API formatting.
|
||||
// They use the generic JSON:API layer internally.
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Drupal-specific entity operations with simplified API
|
||||
* Handles entity types, bundles, URL construction, and data format conversion
|
||||
*/
|
||||
export const drupal = {
|
||||
/**
|
||||
* Get a single Drupal entity by entity type, bundle, and UUID
|
||||
* @example drupal.getEntity(auth, 'node', 'article', '12345-uuid')
|
||||
*/
|
||||
async getEntity(auth: DrupalAuthType, entityType: string, bundle: string, uuid: string) {
|
||||
const resourcePath = `/jsonapi/${entityType}/${bundle}/${uuid}`;
|
||||
return await jsonApi.get(auth, resourcePath);
|
||||
},
|
||||
|
||||
/**
|
||||
* List Drupal entities with optional filtering, sorting, and field selection
|
||||
* @example drupal.listEntities(auth, 'node', 'article', { filters: { status: '1' }, sort: 'created' })
|
||||
*/
|
||||
async listEntities(auth: DrupalAuthType, entityType: string, bundle: string, options?: {
|
||||
filters?: Record<string, any>;
|
||||
sort?: string;
|
||||
sortDirection?: string;
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
}) {
|
||||
const collectionPath = `/jsonapi/${entityType}/${bundle}`;
|
||||
const queryOptions = options ? {
|
||||
...options,
|
||||
resourceType: `${entityType}--${bundle}`
|
||||
} : undefined;
|
||||
|
||||
return await jsonApi.list(auth, collectionPath, queryOptions);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new Drupal entity with simple object data (automatically converts to JSON:API format)
|
||||
* @example drupal.createEntity(auth, 'node', 'article', { title: 'My Article', body: 'Content...' })
|
||||
*/
|
||||
async createEntity(auth: DrupalAuthType, entityType: string, bundle: string, entityData: Record<string, any>) {
|
||||
const collectionPath = `/jsonapi/${entityType}/${bundle}`;
|
||||
const jsonApiData = toJsonApiFormat(entityType, bundle, entityData);
|
||||
|
||||
return await jsonApi.create(auth, collectionPath, jsonApiData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a Drupal entity with simple object data (automatically converts to JSON:API format)
|
||||
* @example drupal.updateEntity(auth, 'node', 'article', '12345-uuid', { title: 'Updated Title' })
|
||||
*/
|
||||
async updateEntity(auth: DrupalAuthType, entityType: string, bundle: string, uuid: string, entityData: Record<string, any>) {
|
||||
const resourcePath = `/jsonapi/${entityType}/${bundle}/${uuid}`;
|
||||
const jsonApiData = toJsonApiFormat(entityType, bundle, entityData, uuid);
|
||||
|
||||
return await jsonApi.update(auth, resourcePath, jsonApiData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a Drupal entity
|
||||
* @example drupal.deleteEntity(auth, 'node', 'article', '12345-uuid')
|
||||
*/
|
||||
async deleteEntity(auth: DrupalAuthType, entityType: string, bundle: string, uuid: string) {
|
||||
const resourcePath = `/jsonapi/${entityType}/${bundle}/${uuid}`;
|
||||
return await jsonApi.delete(auth, resourcePath);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
createTrigger,
|
||||
TriggerStrategy,
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
DedupeStrategy,
|
||||
httpClient,
|
||||
HttpMethod,
|
||||
Polling,
|
||||
pollingHelper,
|
||||
} from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { DrupalAuthType } from '../common/jsonapi';
|
||||
|
||||
const polling: Polling<DrupalAuthType, { name: string }> = {
|
||||
strategy: DedupeStrategy.LAST_ITEM,
|
||||
items: async ({ auth, propsValue, lastItemId }) => {
|
||||
if (lastItemId === undefined || lastItemId === null) {
|
||||
lastItemId = '0';
|
||||
}
|
||||
console.debug('Polling by ID', propsValue['name'], lastItemId);
|
||||
const { website_url, username, password } = auth.props;
|
||||
const body: any = {
|
||||
name: propsValue['name'],
|
||||
id: lastItemId,
|
||||
};
|
||||
const response = await httpClient.sendRequest<DrupalPollItemId[]>({
|
||||
method: HttpMethod.POST,
|
||||
url: website_url + `/orchestration/poll`,
|
||||
body: body,
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
});
|
||||
console.debug('Poll response', response);
|
||||
console.debug('Poll response', JSON.stringify(response.body));
|
||||
return response.body.reverse().map((item) => ({
|
||||
id: item.id,
|
||||
data: item.data,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export const drupalPollingId = createTrigger({
|
||||
auth: drupalAuth,
|
||||
name: 'drupalPollingId',
|
||||
displayName: 'Polling by ID',
|
||||
description: 'A trigger that polls the Drupal site by ID.',
|
||||
props: {
|
||||
name: Property.ShortText({
|
||||
displayName: 'Name',
|
||||
description: 'The name identifies the poll. It must be unique. It will be used to identify the poll in the Drupal site, e.g. if you use ECA to respond to this poll, you need to use the same name in the configuration of its poll event.',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
sampleData: {},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async test(context) {
|
||||
const { auth, propsValue, store, files } = context;
|
||||
return await pollingHelper.test(polling, { store, auth, propsValue, files });
|
||||
},
|
||||
async onEnable(context) {
|
||||
const { auth, propsValue, store } = context;
|
||||
await pollingHelper.onEnable(polling, { store, auth, propsValue });
|
||||
},
|
||||
async onDisable(context) {
|
||||
const { auth, propsValue, store } = context;
|
||||
await pollingHelper.onDisable(polling, { store, auth, propsValue });
|
||||
},
|
||||
async run(context) {
|
||||
const { auth, propsValue, store, files } = context;
|
||||
return await pollingHelper.poll(polling, { store, auth, propsValue, files });
|
||||
},
|
||||
});
|
||||
|
||||
interface DrupalPollItemId {
|
||||
data: any;
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
createTrigger,
|
||||
TriggerStrategy,
|
||||
PiecePropValueSchema,
|
||||
Property,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
DedupeStrategy,
|
||||
httpClient,
|
||||
HttpMethod,
|
||||
Polling,
|
||||
pollingHelper,
|
||||
} from '@activepieces/pieces-common';
|
||||
import { drupalAuth } from '../../';
|
||||
import { DrupalAuthType } from '../common/jsonapi';
|
||||
|
||||
const polling: Polling<DrupalAuthType, { name: string }> = {
|
||||
strategy: DedupeStrategy.TIMEBASED,
|
||||
items: async ({ auth, propsValue, lastFetchEpochMS }) => {
|
||||
if (lastFetchEpochMS === undefined || lastFetchEpochMS === null) {
|
||||
lastFetchEpochMS = 0;
|
||||
}
|
||||
console.debug('Polling by timestamp', propsValue['name'], lastFetchEpochMS);
|
||||
const { website_url, username, password } = auth.props;
|
||||
const body: any = {
|
||||
name: propsValue['name'],
|
||||
timestamp: lastFetchEpochMS / 1000,
|
||||
};
|
||||
const response = await httpClient.sendRequest<DrupalPollItemTimestamp[]>({
|
||||
method: HttpMethod.POST,
|
||||
url: website_url + `/orchestration/poll`,
|
||||
body: body,
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
});
|
||||
console.debug('Poll response', response);
|
||||
console.debug('Poll response', JSON.stringify(response.body));
|
||||
return response.body.map((item) => ({
|
||||
epochMilliSeconds: item.timestamp * 1000,
|
||||
data: item.data,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export const drupalPollingTimestamp = createTrigger({
|
||||
auth: drupalAuth,
|
||||
name: 'drupalPollingTimestamp',
|
||||
displayName: 'Polling by timestamp',
|
||||
description: 'A trigger that polls the Drupal site by timestamp.',
|
||||
props: {
|
||||
name: Property.ShortText({
|
||||
displayName: 'Name',
|
||||
description: 'The name identifies the poll. It must be unique. It will be used to identify the poll in the Drupal site, e.g. if you use ECA to respond to this poll, you need to use the same name in the configuration of its poll event.',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
sampleData: {},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async test(context) {
|
||||
const { auth, propsValue, store, files } = context;
|
||||
return await pollingHelper.test(polling, { store, auth, propsValue, files });
|
||||
},
|
||||
async onEnable(context) {
|
||||
const { auth, propsValue, store } = context;
|
||||
await pollingHelper.onEnable(polling, { store, auth, propsValue });
|
||||
},
|
||||
async onDisable(context) {
|
||||
const { auth, propsValue, store } = context;
|
||||
await pollingHelper.onDisable(polling, { store, auth, propsValue });
|
||||
},
|
||||
async run(context) {
|
||||
const { auth, propsValue, store, files } = context;
|
||||
return await pollingHelper.poll(polling, { store, auth, propsValue, files });
|
||||
},
|
||||
});
|
||||
|
||||
interface DrupalPollItemTimestamp {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
httpClient,
|
||||
HttpMethod,
|
||||
} from '@activepieces/pieces-common';
|
||||
import {
|
||||
createTrigger,
|
||||
Property,
|
||||
TriggerStrategy,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import { drupalAuth } from '../../';
|
||||
import { DrupalAuthType } from '../common/jsonapi';
|
||||
|
||||
export const drupalWebhook = createTrigger({
|
||||
auth: drupalAuth,
|
||||
name: 'drupalWebhook',
|
||||
displayName: 'Webhook',
|
||||
description: 'A webhook that the Drupal site can call to trigger a flow.',
|
||||
props: {
|
||||
id: Property.ShortText({
|
||||
displayName: 'Name',
|
||||
description: 'This name identifies the webhook. It must be unique. It will be used to identify the webhook in the Drupal site, e.g. if you use ECA to call this webhook, you will find this name in the list of available webhooks.',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
sampleData: {},
|
||||
type: TriggerStrategy.WEBHOOK,
|
||||
async onEnable(context) {
|
||||
const { website_url, username, password } = context.auth.props;
|
||||
const body: any = {
|
||||
id: context.propsValue.id,
|
||||
webHookUrl: context.webhookUrl,
|
||||
};
|
||||
const response = await httpClient.sendRequest({
|
||||
method: HttpMethod.POST,
|
||||
url: website_url + `/orchestration/webhook/register`,
|
||||
body: body,
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
});
|
||||
console.debug('Webhook register response', response);
|
||||
await context.store.put(`_drupal_webhook_trigger_` + context.propsValue.id, response.body);
|
||||
},
|
||||
async onDisable(context) {
|
||||
const { website_url, username, password } = context.auth.props;
|
||||
const webhook = await context.store.get(`_drupal_webhook_trigger` + context.propsValue.id);
|
||||
if (webhook) {
|
||||
const response = await httpClient.sendRequest({
|
||||
method: HttpMethod.POST,
|
||||
url: website_url + `/orchestration/webhook/unregister`,
|
||||
body: webhook,
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
});
|
||||
console.debug('Webhook unregister response', response);
|
||||
}
|
||||
},
|
||||
async run(context) {
|
||||
return [context.payload.body];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user