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:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,29 @@
import { createPiece } from "@activepieces/pieces-framework";
import { PieceCategory } from "@activepieces/shared";
import { featheryAuth } from "./lib/common/auth";
import { createFormAction } from "./lib/actions/create-form";
import { updateFormAction } from "./lib/actions/update-form";
import { deleteFormAction } from "./lib/actions/delete-form";
import { listFormSubmissionsAction } from "./lib/actions/list-form-submissions";
import { exportSubmissionPdfAction } from "./lib/actions/export-submission-pdf";
import { newSubmissionTrigger } from "./lib/triggers/new-submission";
import { formCompletedTrigger } from "./lib/triggers/form-completed";
import { fileSubmittedTrigger } from "./lib/triggers/file-submitted";
export const feathery = createPiece({
displayName: "Feathery",
description: "Build powerful forms, workflows, and document automation.",
auth: featheryAuth,
minimumSupportedRelease: '0.36.1',
logoUrl: "https://cdn.activepieces.com/pieces/feathery.png",
categories: [PieceCategory.FORMS_AND_SURVEYS],
authors: ["onyedikachi-david"],
actions: [
createFormAction,
updateFormAction,
deleteFormAction,
listFormSubmissionsAction,
exportSubmissionPdfAction,
],
triggers: [newSubmissionTrigger, formCompletedTrigger, fileSubmittedTrigger],
});

View File

@@ -0,0 +1,633 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { HttpMethod } from '@activepieces/pieces-common';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
export const createFormAction = createAction({
auth: featheryAuth,
name: 'create_form',
displayName: 'Create Form',
description: 'Create a form based on an existing template form.',
props: {
form_name: Property.ShortText({
displayName: 'Form Name',
description: 'The name of the new form (must be unique).',
required: true,
}),
template_form_id: Property.Dropdown({
displayName: 'Template Form',
description: 'Select the template form to copy from.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
enabled: Property.Checkbox({
displayName: 'Enabled',
description: 'Whether the created form should be enabled. If not set, inherits from template.',
required: false,
}),
steps: Property.Array({
displayName: 'Steps',
description: 'Define the steps for your form.',
required: true,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'Unique ID for this step.',
required: true,
}),
template_step_id: Property.ShortText({
displayName: 'Template Step ID',
description: 'ID of the step to copy from the template. Leave empty to auto-create.',
required: false,
}),
origin: Property.Checkbox({
displayName: 'Is First Step',
description: 'Set to true if this is the first step of the form.',
required: false,
}),
},
}),
step_images: Property.Array({
displayName: 'Step Images',
description: 'Edit image elements on steps.',
required: false,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'The step containing this image.',
required: true,
}),
id: Property.ShortText({
displayName: 'Image Element ID',
description: 'ID of the image element to edit.',
required: true,
}),
source_url: Property.ShortText({
displayName: 'Image URL',
description: 'New image URL.',
required: false,
}),
asset: Property.ShortText({
displayName: 'Asset Name',
description: 'Name of the image asset from the template theme.',
required: false,
}),
},
}),
step_videos: Property.Array({
displayName: 'Step Videos',
description: 'Edit video elements on steps.',
required: false,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'The step containing this video.',
required: true,
}),
id: Property.ShortText({
displayName: 'Video Element ID',
description: 'ID of the video element to edit.',
required: true,
}),
source_url: Property.ShortText({
displayName: 'Video URL',
description: 'New video URL.',
required: false,
}),
asset: Property.ShortText({
displayName: 'Asset Name',
description: 'Name of the video asset from the template theme.',
required: false,
}),
},
}),
step_texts: Property.Array({
displayName: 'Step Texts',
description: 'Edit text elements on steps.',
required: false,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'The step containing this text element.',
required: true,
}),
id: Property.ShortText({
displayName: 'Text Element ID',
description: 'ID of the text element to edit.',
required: true,
}),
text: Property.LongText({
displayName: 'Text',
description: 'New text to display.',
required: false,
}),
asset: Property.ShortText({
displayName: 'Asset Name',
description: 'Name of the text asset from the template theme.',
required: false,
}),
},
}),
step_buttons: Property.Array({
displayName: 'Step Buttons',
description: 'Edit button elements on steps.',
required: false,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'The step containing this button.',
required: true,
}),
id: Property.ShortText({
displayName: 'Button Element ID',
description: 'ID of the button to edit.',
required: true,
}),
text: Property.ShortText({
displayName: 'Button Text',
description: 'Text to display on the button.',
required: false,
}),
asset: Property.ShortText({
displayName: 'Asset Name',
description: 'Name of the button asset from the template theme.',
required: false,
}),
},
}),
step_progress_bars: Property.Array({
displayName: 'Step Progress Bars',
description: 'Edit progress bar elements on steps.',
required: false,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'The step containing this progress bar.',
required: true,
}),
id: Property.ShortText({
displayName: 'Progress Bar ID',
description: 'ID of the progress bar to edit.',
required: true,
}),
progress: Property.Number({
displayName: 'Progress',
description: 'Progress percentage (0-100).',
required: false,
}),
asset: Property.ShortText({
displayName: 'Asset Name',
description: 'Name of the progress bar asset from the template theme.',
required: false,
}),
},
}),
step_fields: Property.Array({
displayName: 'Step Fields',
description: 'Edit field elements on steps.',
required: false,
properties: {
step_id: Property.ShortText({
displayName: 'Step ID',
description: 'The step containing this field.',
required: true,
}),
id: Property.ShortText({
displayName: 'Field Element ID',
description: 'ID of the field to copy.',
required: true,
}),
field_id: Property.ShortText({
displayName: 'New Field ID',
description: 'ID to set for the field. Links to existing field data if ID exists.',
required: false,
}),
type: Property.StaticDropdown({
displayName: 'Field Type',
description: 'Type of the field.',
required: false,
options: {
options: [
{ label: 'Text Field', value: 'text_field' },
{ label: 'Text Area', value: 'text_area' },
{ label: 'Integer Field', value: 'integer_field' },
{ label: 'Email', value: 'email' },
{ label: 'Phone Number', value: 'phone_number' },
{ label: 'Dropdown', value: 'dropdown' },
{ label: 'Select', value: 'select' },
{ label: 'Multiselect', value: 'multiselect' },
{ label: 'Checkbox', value: 'checkbox' },
{ label: 'Date', value: 'date' },
{ label: 'File Upload', value: 'file_upload' },
{ label: 'Button Group', value: 'button_group' },
{ label: 'URL', value: 'url' },
{ label: 'SSN', value: 'ssn' },
{ label: 'Pin Input', value: 'pin_input' },
{ label: 'Hex Color', value: 'hex_color' },
{ label: 'Login', value: 'login' },
{ label: 'Google Maps Line 1', value: 'gmap_line_1' },
{ label: 'Google Maps Line 2', value: 'gmap_line_2' },
{ label: 'Google Maps City', value: 'gmap_city' },
{ label: 'Google Maps State', value: 'gmap_state' },
{ label: 'Google Maps Zip', value: 'gmap_zip' },
],
},
}),
description: Property.ShortText({
displayName: 'Label',
description: 'Label/description of the field.',
required: false,
}),
required: Property.Checkbox({
displayName: 'Required',
description: 'Whether the field is required.',
required: false,
}),
placeholder: Property.ShortText({
displayName: 'Placeholder',
description: 'Placeholder text for the field.',
required: false,
}),
tooltipText: Property.ShortText({
displayName: 'Tooltip Text',
description: 'Tooltip text for the field.',
required: false,
}),
max_length: Property.Number({
displayName: 'Max Length',
description: 'Maximum length of the field value.',
required: false,
}),
min_length: Property.Number({
displayName: 'Min Length',
description: 'Minimum length of the field value.',
required: false,
}),
submit_trigger: Property.StaticDropdown({
displayName: 'Submit Trigger',
description: 'Does filling out this field trigger step submission?',
required: false,
options: {
options: [
{ label: 'None', value: 'none' },
{ label: 'Auto', value: 'auto' },
],
},
}),
asset: Property.ShortText({
displayName: 'Asset Name',
description: 'Name of the field asset from the template theme.',
required: false,
}),
},
}),
navigation_rules: Property.Array({
displayName: 'Navigation Rules',
description: 'Define how users navigate between steps.',
required: false,
properties: {
previous_step_id: Property.ShortText({
displayName: 'From Step ID',
description: 'The step the user is coming from.',
required: true,
}),
next_step_id: Property.ShortText({
displayName: 'To Step ID',
description: 'The step the user is going to.',
required: true,
}),
trigger: Property.StaticDropdown({
displayName: 'Trigger',
description: 'How navigation is triggered.',
required: true,
options: {
options: [
{ label: 'Click', value: 'click' },
{ label: 'Change', value: 'change' },
{ label: 'Load', value: 'load' },
],
},
}),
element_type: Property.StaticDropdown({
displayName: 'Element Type',
description: 'Type of element that triggers navigation.',
required: true,
options: {
options: [
{ label: 'Button', value: 'button' },
{ label: 'Field', value: 'field' },
{ label: 'Text', value: 'text' },
],
},
}),
element_id: Property.ShortText({
displayName: 'Element ID',
description: 'ID of the element that triggers navigation.',
required: true,
}),
},
}),
navigation_conditions: Property.Array({
displayName: 'Navigation Conditions',
description: 'Conditions for navigation rules. Reference the navigation rule by its index (0-based).',
required: false,
properties: {
rule_index: Property.Number({
displayName: 'Rule Index',
description: 'Index of the navigation rule (0 for first rule, 1 for second, etc.).',
required: true,
}),
comparison: Property.StaticDropdown({
displayName: 'Comparison',
description: 'Type of comparison.',
required: true,
options: {
options: [
{ label: 'Equal', value: 'equal' },
{ label: 'Not Equal', value: 'not_equal' },
],
},
}),
field_key: Property.ShortText({
displayName: 'Field Key',
description: 'ID of the field to compare.',
required: true,
}),
value: Property.ShortText({
displayName: 'Value',
description: 'Value to compare against.',
required: true,
}),
},
}),
logic_rules: Property.Array({
displayName: 'Logic Rules',
description: 'Advanced logic rules for the form.',
required: false,
properties: {
name: Property.ShortText({
displayName: 'Rule Name',
description: 'Name of the logic rule.',
required: true,
}),
code: Property.LongText({
displayName: 'JavaScript Code',
description: 'JavaScript code to run.',
required: true,
}),
trigger_event: Property.StaticDropdown({
displayName: 'Trigger Event',
description: 'Event that triggers the rule.',
required: true,
options: {
options: [
{ label: 'Change', value: 'change' },
{ label: 'Load', value: 'load' },
{ label: 'Form Complete', value: 'form_complete' },
{ label: 'Submit', value: 'submit' },
{ label: 'Error', value: 'error' },
{ label: 'View', value: 'view' },
{ label: 'Action', value: 'action' },
],
},
}),
description: Property.ShortText({
displayName: 'Description',
description: 'Description of the logic rule.',
required: false,
}),
index: Property.Number({
displayName: 'Execution Order',
description: 'Order in which this rule executes.',
required: false,
}),
steps_csv: Property.ShortText({
displayName: 'Steps (comma-separated)',
description: 'Step IDs that trigger the rule (for submit/load events). Separate with commas.',
required: false,
}),
elements_csv: Property.ShortText({
displayName: 'Elements (comma-separated)',
description: 'Element IDs that trigger the rule (for change/error/view/action events). Separate with commas.',
required: false,
}),
},
}),
},
async run(context) {
const {
form_name,
template_form_id,
enabled,
steps,
step_images,
step_videos,
step_texts,
step_buttons,
step_progress_bars,
step_fields,
navigation_rules,
navigation_conditions,
logic_rules,
} = context.propsValue;
const stepsMap = new Map<string, any>();
for (const step of steps as any[]) {
stepsMap.set(step.step_id, {
step_id: step.step_id,
...(step.template_step_id && { template_step_id: step.template_step_id }),
...(step.origin && { origin: step.origin }),
images: [],
videos: [],
texts: [],
buttons: [],
progress_bars: [],
fields: [],
});
}
if (step_images && Array.isArray(step_images)) {
for (const img of step_images as any[]) {
const step = stepsMap.get(img.step_id);
if (step) {
step.images.push({
id: img.id,
...(img.source_url && { source_url: img.source_url }),
...(img.asset && { asset: img.asset }),
});
}
}
}
if (step_videos && Array.isArray(step_videos)) {
for (const video of step_videos as any[]) {
const step = stepsMap.get(video.step_id);
if (step) {
step.videos.push({
id: video.id,
...(video.source_url && { source_url: video.source_url }),
...(video.asset && { asset: video.asset }),
});
}
}
}
if (step_texts && Array.isArray(step_texts)) {
for (const txt of step_texts as any[]) {
const step = stepsMap.get(txt.step_id);
if (step) {
step.texts.push({
id: txt.id,
...(txt.text && { text: txt.text }),
...(txt.asset && { asset: txt.asset }),
});
}
}
}
if (step_buttons && Array.isArray(step_buttons)) {
for (const btn of step_buttons as any[]) {
const step = stepsMap.get(btn.step_id);
if (step) {
step.buttons.push({
id: btn.id,
...(btn.text && { text: btn.text }),
...(btn.asset && { asset: btn.asset }),
});
}
}
}
if (step_progress_bars && Array.isArray(step_progress_bars)) {
for (const pb of step_progress_bars as any[]) {
const step = stepsMap.get(pb.step_id);
if (step) {
step.progress_bars.push({
id: pb.id,
...(pb.progress !== undefined && { progress: pb.progress }),
...(pb.asset && { asset: pb.asset }),
});
}
}
}
if (step_fields && Array.isArray(step_fields)) {
for (const field of step_fields as any[]) {
const step = stepsMap.get(field.step_id);
if (step) {
step.fields.push({
id: field.id,
...(field.field_id && { field_id: field.field_id }),
...(field.type && { type: field.type }),
...(field.description && { description: field.description }),
...(field.required !== undefined && { required: field.required }),
...(field.placeholder && { placeholder: field.placeholder }),
...(field.tooltipText && { tooltipText: field.tooltipText }),
...(field.max_length !== undefined && { max_length: field.max_length }),
...(field.min_length !== undefined && { min_length: field.min_length }),
...(field.submit_trigger && { submit_trigger: field.submit_trigger }),
...(field.asset && { asset: field.asset }),
});
}
}
}
const navRulesArray: any[] = [];
if (navigation_rules && Array.isArray(navigation_rules)) {
for (const rule of navigation_rules as any[]) {
navRulesArray.push({
previous_step_id: rule.previous_step_id,
next_step_id: rule.next_step_id,
trigger: rule.trigger,
element_type: rule.element_type,
element_id: rule.element_id,
rules: [],
});
}
}
if (navigation_conditions && Array.isArray(navigation_conditions)) {
for (const cond of navigation_conditions as any[]) {
const ruleIndex = cond.rule_index;
if (ruleIndex >= 0 && ruleIndex < navRulesArray.length) {
navRulesArray[ruleIndex].rules.push({
comparison: cond.comparison,
field_key: cond.field_key,
value: cond.value,
});
}
}
}
const logicRulesArray: any[] = [];
if (logic_rules && Array.isArray(logic_rules)) {
for (const rule of logic_rules as any[]) {
logicRulesArray.push({
name: rule.name,
code: rule.code,
trigger_event: rule.trigger_event,
...(rule.description && { description: rule.description }),
...(rule.index !== undefined && { index: rule.index }),
...(rule.steps_csv && { steps: rule.steps_csv.split(',').map((s: string) => s.trim()) }),
...(rule.elements_csv && { elements: rule.elements_csv.split(',').map((s: string) => s.trim()) }),
});
}
}
const body: any = {
form_name,
template_form_id,
steps: Array.from(stepsMap.values()),
navigation_rules: navRulesArray,
};
if (enabled !== undefined) {
body.enabled = enabled;
}
if (logicRulesArray.length > 0) {
body.logic_rules = logicRulesArray;
}
const response = await featheryCommon.apiCall<{
id: string;
name: string;
internal_id?: string;
}>({
method: HttpMethod.POST,
url: '/form/',
apiKey: context.auth.secret_text,
body,
});
return response;
},
});

View File

@@ -0,0 +1,71 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { HttpMethod } from '@activepieces/pieces-common';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
export const deleteFormAction = createAction({
auth: featheryAuth,
name: 'delete_form',
displayName: 'Delete Form',
description: 'Delete a specific form.',
props: {
form_id: Property.Dropdown({
displayName: 'Form',
description: 'Select the form to delete.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
confirm_delete: Property.Checkbox({
displayName: 'Confirm Delete',
description: 'Check to confirm deletion. This action cannot be undone.',
required: true,
defaultValue: false,
}),
},
async run(context) {
const { form_id, confirm_delete } = context.propsValue;
if (!confirm_delete) {
throw new Error('You must confirm the deletion by checking the confirm box.');
}
await featheryCommon.apiCall({
method: HttpMethod.DELETE,
url: `/form/${form_id}/`,
apiKey: context.auth.secret_text,
body: { confirm_delete: true },
});
return {
success: true,
message: `Form ${form_id} deleted successfully.`,
};
},
});

View File

@@ -0,0 +1,95 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { HttpMethod } from '@activepieces/pieces-common';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
export const exportSubmissionPdfAction = createAction({
auth: featheryAuth,
name: 'export_submission_pdf',
displayName: 'Export Form Submission PDF',
description: 'Create a PDF export for a specific form submission.',
props: {
form_id: Property.Dropdown({
displayName: 'Form',
description: 'Select the form to export submission from.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
user_id: Property.Dropdown({
displayName: 'User',
description: 'Select the user whose submission to export.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const users = await featheryCommon.apiCall<
Array<{ id: string; created_at: string; updated_at: string }>
>({
method: HttpMethod.GET,
url: '/user/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: users.map((user) => ({
label: user.id,
value: user.id,
})),
};
},
}),
},
async run(context) {
const { form_id, user_id } = context.propsValue;
const response = await featheryCommon.apiCall<{
pdf_url: string;
}>({
method: HttpMethod.POST,
url: '/form/submission/pdf/',
apiKey: context.auth.secret_text,
body: {
form_id,
user_id,
},
});
return response;
},
});

View File

@@ -0,0 +1,269 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { HttpMethod } from '@activepieces/pieces-common';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
export const listFormSubmissionsAction = createAction({
auth: featheryAuth,
name: 'list_form_submissions',
displayName: 'List Form Submissions',
description: 'List submission data for a particular form.',
props: {
form_id: Property.Dropdown({
displayName: 'Form',
description: 'Select the form to get submissions for.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
start_time: Property.DateTime({
displayName: 'Start Time',
description: 'Limit submissions to after this update time.',
required: false,
}),
end_time: Property.DateTime({
displayName: 'End Time',
description: 'Limit submissions to before this update time.',
required: false,
}),
created_after: Property.DateTime({
displayName: 'Created After',
description: 'Limit submissions to after this creation time.',
required: false,
}),
created_before: Property.DateTime({
displayName: 'Created Before',
description: 'Limit submissions to before this creation time.',
required: false,
}),
count: Property.Number({
displayName: 'Count',
description: 'Limit the number of returned submissions.',
required: false,
}),
completed: Property.StaticDropdown({
displayName: 'Completion Status',
description: 'Filter by completion status.',
required: false,
options: {
options: [
{ label: 'All', value: '' },
{ label: 'Completed Only', value: 'true' },
{ label: 'Incomplete Only', value: 'false' },
],
},
}),
fields: Property.ShortText({
displayName: 'Fields',
description: 'Comma-separated list of field IDs to return.',
required: false,
}),
no_field_values: Property.Checkbox({
displayName: 'Exclude Field Values',
description: 'Don\'t return field data. More performant for large datasets.',
required: false,
}),
sort: Property.StaticDropdown({
displayName: 'Sort',
description: 'How to sort the returned field values.',
required: false,
options: {
options: [
{ label: 'Alphabetically by Field ID', value: '' },
{ label: 'By Form Layout', value: 'layout' },
],
},
}),
page_size: Property.Number({
displayName: 'Page Size',
description: 'Number of results per page (default 500, max 1000).',
required: false,
}),
use_cache: Property.Checkbox({
displayName: 'Use Cache',
description: 'Use cached data for faster response (may be a few minutes old).',
required: false,
}),
field_search: Property.Array({
displayName: 'Field Search',
description: 'Search for submissions with specific field values.',
required: false,
properties: {
field_id: Property.ShortText({
displayName: 'Field ID',
description: 'The field ID to search.',
required: true,
}),
value: Property.ShortText({
displayName: 'Value',
description: 'The value to match.',
required: true,
}),
},
}),
fuzzy_search_threshold: Property.Number({
displayName: 'Fuzzy Search Threshold',
description: 'Score threshold between 0 and 1 for fuzzy search.',
required: false,
}),
fuzzy_search_parameters: Property.Array({
displayName: 'Fuzzy Search Parameters',
description: 'Fields and terms for fuzzy search. Weights must sum to 1.',
required: false,
properties: {
field_id: Property.ShortText({
displayName: 'Field ID',
description: 'The field to compare.',
required: true,
}),
term: Property.ShortText({
displayName: 'Search Term',
description: 'The term to search for.',
required: true,
}),
weight: Property.Number({
displayName: 'Weight',
description: 'Importance of this field (0-1). All weights must sum to 1.',
required: true,
}),
},
}),
},
async run(context) {
const {
form_id,
start_time,
end_time,
created_after,
created_before,
count,
completed,
fields,
no_field_values,
sort,
page_size,
use_cache,
field_search,
fuzzy_search_threshold,
fuzzy_search_parameters,
} = context.propsValue;
const queryParams = new URLSearchParams();
queryParams.append('form_id', form_id as string);
if (start_time) {
queryParams.append('start_time', start_time);
}
if (end_time) {
queryParams.append('end_time', end_time);
}
if (created_after) {
queryParams.append('created_after', created_after);
}
if (created_before) {
queryParams.append('created_before', created_before);
}
if (count !== undefined) {
queryParams.append('count', count.toString());
}
if (completed && completed !== '') {
queryParams.append('completed', completed);
}
if (fields) {
queryParams.append('fields', fields);
}
if (no_field_values) {
queryParams.append('no_field_values', 'true');
}
if (sort && sort !== '') {
queryParams.append('sort', sort);
}
if (page_size !== undefined) {
queryParams.append('page_size', page_size.toString());
}
if (use_cache) {
queryParams.append('use_cache', 'true');
}
if (field_search && Array.isArray(field_search) && field_search.length > 0) {
const fieldSearchArray = (field_search as Array<{ field_id: string; value: string }>).map(
(fs) => ({ field_id: fs.field_id, value: fs.value })
);
queryParams.append('field_search', JSON.stringify(fieldSearchArray));
}
if (
fuzzy_search_threshold !== undefined &&
fuzzy_search_parameters &&
Array.isArray(fuzzy_search_parameters) &&
fuzzy_search_parameters.length > 0
) {
const fuzzySearch = {
threshold: fuzzy_search_threshold,
parameters: (
fuzzy_search_parameters as Array<{ field_id: string; term: string; weight: number }>
).map((p) => ({
field_id: p.field_id,
term: p.term,
weight: p.weight,
})),
};
queryParams.append('fuzzy_search', JSON.stringify(fuzzySearch));
}
const response = await featheryCommon.apiCall<{
count: number;
next: string | null;
previous: string | null;
total_pages: number;
current_page: number;
results: Array<{
values: Array<{
id: string;
type: string;
created_at: string;
updated_at: string;
value: unknown;
hidden: boolean;
display_text: string;
internal_id: string;
}>;
user_id: string;
submission_start: string;
last_submitted: string;
}>;
}>({
method: HttpMethod.GET,
url: `/form/submission/?${queryParams.toString()}`,
apiKey: context.auth.secret_text,
});
return response;
},
});

View File

@@ -0,0 +1,120 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { HttpMethod } from '@activepieces/pieces-common';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
export const updateFormAction = createAction({
auth: featheryAuth,
name: 'update_form',
displayName: 'Update Form',
description: 'Update a form\'s properties including status and translations.',
props: {
form_id: Property.Dropdown({
displayName: 'Form',
description: 'Select the form to update.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
form_name: Property.ShortText({
displayName: 'Form Name',
description: 'New name for the form.',
required: false,
}),
enabled: Property.Checkbox({
displayName: 'Enabled',
description: 'Whether the form should be enabled or disabled.',
required: false,
}),
translations: Property.Array({
displayName: 'Translations',
description: 'Add translations for form text. Note: This will override existing translations.',
required: false,
properties: {
default_text: Property.ShortText({
displayName: 'Default Text',
description: 'The original text to translate.',
required: true,
}),
language_code: Property.ShortText({
displayName: 'Language Code',
description: 'Language code (e.g., "es" for Spanish, "fr" for French).',
required: true,
}),
translation: Property.ShortText({
displayName: 'Translation',
description: 'The translated text.',
required: true,
}),
},
}),
},
async run(context) {
const { form_id, form_name, enabled, translations } = context.propsValue;
const body: Record<string, unknown> = {};
if (form_name) {
body['form_name'] = form_name;
}
if (enabled !== undefined) {
body['enabled'] = enabled;
}
if (translations && Array.isArray(translations) && translations.length > 0) {
const translationsObj: Record<string, Record<string, string>> = {};
for (const t of translations as Array<{
default_text: string;
language_code: string;
translation: string;
}>) {
if (!translationsObj[t.default_text]) {
translationsObj[t.default_text] = {};
}
translationsObj[t.default_text][t.language_code] = t.translation;
}
body['translations'] = translationsObj;
}
const response = await featheryCommon.apiCall<{
enabled: boolean;
form_name: string;
}>({
method: HttpMethod.PATCH,
url: `/form/${form_id}/`,
apiKey: context.auth.secret_text,
body,
});
return response;
},
});

View File

@@ -0,0 +1,37 @@
import { PieceAuth } from '@activepieces/pieces-framework';
import { HttpMethod } from '@activepieces/pieces-common';
import { featheryCommon } from './client';
export const featheryAuth = PieceAuth.SecretText({
displayName: 'API Key',
description: 'Your Feathery admin API key. You can get an API key by creating an account.',
required: true,
validate: async ({ auth }) => {
try {
await featheryCommon.apiCall({
method: HttpMethod.GET,
url: '/account/',
apiKey: auth,
});
return {
valid: true,
message: 'API key validated successfully. Connected to Feathery.'
};
} catch (error: any) {
if (error.message.includes('401') || error.message.includes('403')) {
return {
valid: false,
error: 'Invalid API key. Please check your API key and try again.',
};
}
return {
valid: false,
error: `Authentication failed: ${error.message}. Please verify your API key is correct.`,
};
}
},
});

View File

@@ -0,0 +1,38 @@
import { httpClient, HttpMethod } from '@activepieces/pieces-common';
export const featheryCommon = {
baseUrl: 'https://api.feathery.io/api',
async apiCall<T>({
method,
url,
body,
apiKey,
headers,
}: {
method: HttpMethod;
url: string;
body?: any;
apiKey: string;
headers?: Record<string, string>;
}): Promise<T> {
const response = await httpClient.sendRequest<T>({
method,
url: `${this.baseUrl}${url}`,
headers: {
'Authorization': `Token ${apiKey}`,
'Content-Type': 'application/json',
...headers,
},
body,
});
if (response.status >= 400) {
throw new Error(`Feathery API error: ${response.status} - ${JSON.stringify(response.body)}`);
}
return response.body;
},
};

View File

@@ -0,0 +1,159 @@
import {
createTrigger,
Property,
TriggerStrategy,
AppConnectionValueForAuthProperty,
} from '@activepieces/pieces-framework';
import {
DedupeStrategy,
Polling,
pollingHelper,
HttpMethod,
} from '@activepieces/pieces-common';
import dayjs from 'dayjs';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
const polling: Polling<
AppConnectionValueForAuthProperty<typeof featheryAuth>,
{ lookup_type: string; document_id?: string; user_id?: string }
> = {
strategy: DedupeStrategy.TIMEBASED,
items: async ({ auth, propsValue }) => {
const { lookup_type, document_id, user_id } = propsValue;
let url: string;
if (lookup_type === 'document') {
if (!document_id) {
throw new Error('Please enter a Document ID to monitor for file submissions.');
}
url = `/document/envelope/?type=document&id=${encodeURIComponent(document_id)}`;
} else if (lookup_type === 'user') {
if (!user_id) {
throw new Error('Please select a User to monitor for file submissions.');
}
url = `/document/envelope/?type=user&id=${encodeURIComponent(user_id)}`;
} else {
throw new Error('Please select a lookup type (Document Template or User).');
}
try {
const envelopes = await featheryCommon.apiCall<
Array<{
id: string;
document: string;
user: string;
signer: string;
sender: string;
file: string;
type: string;
viewed: boolean;
signed: boolean;
tags: string[];
created_at: string;
}>
>({
method: HttpMethod.GET,
url,
apiKey: auth.secret_text,
});
// Handle case where API returns error object instead of array
if (!Array.isArray(envelopes)) {
return [];
}
return envelopes.map((envelope) => ({
epochMilliSeconds: dayjs(envelope.created_at).valueOf(),
data: envelope,
}));
} catch {
// Return empty if no documents/users found
return [];
}
},
};
export const fileSubmittedTrigger = createTrigger({
auth: featheryAuth,
name: 'file_submitted',
displayName: 'File Submitted',
description: 'Triggers when a file is submitted in your form, or when a document is signed/generated.',
props: {
lookup_type: Property.StaticDropdown({
displayName: 'Lookup By',
description: 'Choose how to find document envelopes.',
required: true,
options: {
options: [
{ label: 'Document Template', value: 'document' },
{ label: 'User', value: 'user' },
],
},
defaultValue: 'document',
}),
document_id: Property.ShortText({
displayName: 'Document ID',
description: 'The ID of the document template to monitor.',
required: false,
}),
user_id: Property.Dropdown({
displayName: 'User',
description: 'Select the user to monitor for file submissions.',
required: false,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const users = await featheryCommon.apiCall<
Array<{ id: string; created_at: string; updated_at: string }>
>({
method: HttpMethod.GET,
url: '/user/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: users.map((user) => ({
label: user.id,
value: user.id,
})),
};
},
}),
},
type: TriggerStrategy.POLLING,
sampleData: {
id: 'envelope-123',
document: 'doc-456',
user: 'user@example.com',
signer: 'signer@example.com',
sender: 'sender@example.com',
file: 'https://link-to-filled-file.com',
type: 'pdf',
viewed: true,
signed: true,
tags: ['document-tag'],
created_at: '2024-06-03T00:00:00Z',
},
async onEnable(context) {
await pollingHelper.onEnable(polling, context);
},
async onDisable(context) {
await pollingHelper.onDisable(polling, context);
},
async run(context) {
return await pollingHelper.poll(polling, context);
},
async test(context) {
return await pollingHelper.test(polling, context);
},
});

View File

@@ -0,0 +1,129 @@
import {
createTrigger,
Property,
TriggerStrategy,
AppConnectionValueForAuthProperty,
} from '@activepieces/pieces-framework';
import {
DedupeStrategy,
Polling,
pollingHelper,
HttpMethod,
} from '@activepieces/pieces-common';
import dayjs from 'dayjs';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
const polling: Polling<AppConnectionValueForAuthProperty<typeof featheryAuth>, { form_id: string }> = {
strategy: DedupeStrategy.TIMEBASED,
items: async ({ auth, propsValue, lastFetchEpochMS }) => {
const { form_id } = propsValue;
const queryParams = new URLSearchParams();
queryParams.append('form_id', form_id);
queryParams.append('completed', 'true');
if (lastFetchEpochMS) {
queryParams.append('start_time', new Date(lastFetchEpochMS).toISOString());
}
const response = await featheryCommon.apiCall<{
count: number;
results: Array<{
values: Array<{
id: string;
type: string;
created_at: string;
updated_at: string;
value: unknown;
hidden: boolean;
display_text: string;
internal_id: string;
}>;
user_id: string;
submission_start: string;
last_submitted: string;
}>;
}>({
method: HttpMethod.GET,
url: `/form/submission/?${queryParams.toString()}`,
apiKey: auth.secret_text,
});
return response.results.map((submission) => ({
epochMilliSeconds: dayjs(submission.last_submitted).valueOf(),
data: submission,
}));
},
};
export const formCompletedTrigger = createTrigger({
auth: featheryAuth,
name: 'form_completed',
displayName: 'Form Completed',
description: 'Triggers when a form is completed by an end user.',
props: {
form_id: Property.Dropdown({
displayName: 'Form',
description: 'Select the form to monitor for completions.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
},
type: TriggerStrategy.POLLING,
sampleData: {
values: [
{
id: 'email_field',
type: 'email',
created_at: '2024-10-28T07:56:09.391398Z',
updated_at: '2024-10-28T16:39:32.577794Z',
value: 'user@example.com',
hidden: false,
display_text: '',
internal_id: 'ef5ed054-73de-4463-ba61-82c36aca5afc',
},
],
user_id: '131e7132-dg6d-4a8c-9d70-cgd493c2a368',
submission_start: '2024-10-30T02:07:32Z',
last_submitted: '2024-10-30T02:07:32Z',
},
async onEnable(context) {
await pollingHelper.onEnable(polling, context);
},
async onDisable(context) {
await pollingHelper.onDisable(polling, context);
},
async run(context) {
return await pollingHelper.poll(polling, context);
},
async test(context) {
return await pollingHelper.test(polling, context);
},
});

View File

@@ -0,0 +1,130 @@
import {
createTrigger,
Property,
TriggerStrategy,
AppConnectionValueForAuthProperty,
} from '@activepieces/pieces-framework';
import {
DedupeStrategy,
Polling,
pollingHelper,
HttpMethod,
} from '@activepieces/pieces-common';
import dayjs from 'dayjs';
import { featheryAuth } from '../common/auth';
import { featheryCommon } from '../common/client';
const polling: Polling<AppConnectionValueForAuthProperty<typeof featheryAuth>, { form_id: string }> = {
strategy: DedupeStrategy.TIMEBASED,
items: async ({ auth, propsValue, lastFetchEpochMS }) => {
const { form_id } = propsValue;
const queryParams = new URLSearchParams();
queryParams.append('form_id', form_id);
// Filter by submissions after last fetch
if (lastFetchEpochMS) {
queryParams.append('start_time', new Date(lastFetchEpochMS).toISOString());
}
const response = await featheryCommon.apiCall<{
count: number;
results: Array<{
values: Array<{
id: string;
type: string;
created_at: string;
updated_at: string;
value: unknown;
hidden: boolean;
display_text: string;
internal_id: string;
}>;
user_id: string;
submission_start: string;
last_submitted: string;
}>;
}>({
method: HttpMethod.GET,
url: `/form/submission/?${queryParams.toString()}`,
apiKey: auth.secret_text,
});
return response.results.map((submission) => ({
epochMilliSeconds: dayjs(submission.last_submitted).valueOf(),
data: submission,
}));
},
};
export const newSubmissionTrigger = createTrigger({
auth: featheryAuth,
name: 'new_submission',
displayName: 'New Form Submission',
description: 'Triggers when a user submits data through your form.',
props: {
form_id: Property.Dropdown({
displayName: 'Form',
description: 'Select the form to monitor for submissions.',
required: true,
refreshers: [],
auth: featheryAuth,
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
placeholder: 'Connect your account first',
options: [],
};
}
const forms = await featheryCommon.apiCall<
Array<{ id: string; name: string; active: boolean }>
>({
method: HttpMethod.GET,
url: '/form/',
apiKey: auth.secret_text,
});
return {
disabled: false,
options: forms.map((form) => ({
label: form.name,
value: form.id,
})),
};
},
}),
},
type: TriggerStrategy.POLLING,
sampleData: {
values: [
{
id: 'email_field',
type: 'email',
created_at: '2024-10-28T07:56:09.391398Z',
updated_at: '2024-10-28T16:39:32.577794Z',
value: 'user@example.com',
hidden: false,
display_text: '',
internal_id: 'ef5ed054-73de-4463-ba61-82c36aca5afc',
},
],
user_id: '131e7132-dg6d-4a8c-9d70-cgd493c2a368',
submission_start: '2024-10-30T02:07:32Z',
last_submitted: '2024-10-30T02:07:32Z',
},
async onEnable(context) {
await pollingHelper.onEnable(polling, context);
},
async onDisable(context) {
await pollingHelper.onDisable(polling, context);
},
async run(context) {
return await pollingHelper.poll(polling, context);
},
async test(context) {
return await pollingHelper.test(polling, context);
},
});