diff --git a/package.json b/package.json index 85ed3a8..e613e6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "taskade", - "version": "1.1.0", + "version": "1.2.0", "description": "Taskade is a collaboration platform for remote teams to organize and manage projects.", "main": "index.js", "scripts": { diff --git a/packages/n8n-nodes-taskade/nodes/Taskade/TaskadeTrigger.node.ts b/packages/n8n-nodes-taskade/nodes/Taskade/TaskadeTrigger.node.ts new file mode 100644 index 0000000..d907e7c --- /dev/null +++ b/packages/n8n-nodes-taskade/nodes/Taskade/TaskadeTrigger.node.ts @@ -0,0 +1,106 @@ +import type { + IDataObject, + IHookFunctions, + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, +} from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { taskadeApiRequest } from './GenericFunctions'; + +export class TaskadeTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Taskade Trigger', + name: 'taskadeTrigger', + icon: 'file:taskade.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["event"]}}', + description: 'Starts a workflow when something happens in Taskade', + defaults: { name: 'Taskade Trigger' }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + credentials: [{ name: 'taskadeApi', required: true }], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Event', + name: 'event', + type: 'options', + options: [ + { name: 'Comment Created', value: 'comment.created' }, + { name: 'Member Joined Project', value: 'project.joined' }, + { name: 'Project Assigned', value: 'project.assigned' }, + { name: 'Project Created', value: 'project.created' }, + { name: 'Task Assigned', value: 'task.assigned' }, + { name: 'Task Due', value: 'task.due' }, + ], + default: 'task.due', + required: true, + description: 'The Taskade event that starts the workflow', + }, + ], + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + return this.getWorkflowStaticData('node').hookId != null; + }, + async create(this: IHookFunctions): Promise { + const response = await taskadeApiRequest.call( + this as never, + 'POST', + '/subscribeWebhook', + { + targetUrl: this.getNodeWebhookUrl('default') as string, + triggerType: this.getNodeParameter('event') as string, + }, + 'v2', + ); + const hookId = (response as IDataObject).hookId; + if (hookId == null) { + return false; + } + this.getWorkflowStaticData('node').hookId = hookId; + return true; + }, + async delete(this: IHookFunctions): Promise { + const staticData = this.getWorkflowStaticData('node'); + if (staticData.hookId == null) { + return true; + } + try { + await taskadeApiRequest.call( + this as never, + 'POST', + '/unsubscribeWebhook', + { hookId: staticData.hookId as string }, + 'v2', + ); + } catch { + // Hook may already be gone server-side; always clear local state so + // a future activation re-subscribes instead of silently never firing. + } + delete staticData.hookId; + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const body = this.getBodyData(); + return { + workflowData: [this.helpers.returnJsonArray(body as IDataObject)], + }; + } +} diff --git a/packages/n8n-nodes-taskade/package.json b/packages/n8n-nodes-taskade/package.json index fea10ac..43e0fee 100644 --- a/packages/n8n-nodes-taskade/package.json +++ b/packages/n8n-nodes-taskade/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-taskade", - "version": "0.1.0", + "version": "0.2.0", "description": "n8n community node for Taskade — tasks, projects, and AI agents on the Taskade public API", "keywords": [ "n8n-community-node-package", @@ -38,7 +38,8 @@ "dist/credentials/TaskadeApi.credentials.js" ], "nodes": [ - "dist/nodes/Taskade/Taskade.node.js" + "dist/nodes/Taskade/Taskade.node.js", + "dist/nodes/Taskade/TaskadeTrigger.node.js" ] }, "devDependencies": { diff --git a/src/creates/addAgentKnowledge.ts b/src/creates/addAgentKnowledge.ts new file mode 100644 index 0000000..f5a127a --- /dev/null +++ b/src/creates/addAgentKnowledge.ts @@ -0,0 +1,73 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const isMedia = bundle.inputData.knowledge_type === 'media'; + + const sourceId = isMedia ? bundle.inputData.media_id : bundle.inputData.project_id; + if (sourceId == null || sourceId === '') { + throw new z.errors.Error( + isMedia ? 'Media ID is required when the source is Media File.' : 'Project is required when the source is Project.', + 'invalid_input', + 400, + ); + } + + const response = await request(z, bundle, { + method: 'POST', + path: `/agents/${bundle.inputData.agent_id}/knowledge/${isMedia ? 'media' : 'project'}`, + body: isMedia + ? { mediaId: bundle.inputData.media_id as string } + : { projectId: bundle.inputData.project_id as string }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { agentId: bundle.inputData.agent_id, added: true }; +}; + +export default { + key: 'add_agent_knowledge', + noun: 'Agent Knowledge', + display: { + label: 'Add Knowledge to AI Agent', + description: 'Adds a project or media file to an AI agent’s knowledge.', + hidden: false, + }, + operation: { + inputFields: [ + { ...spaceField(true), label: 'Folder', altersDynamicFields: true }, + { + key: 'agent_id', + label: 'Agent', + type: 'string', + dynamic: 'get_all_agents.id.title', + required: true, + list: false, + altersDynamicFields: false, + }, + { + key: 'knowledge_type', + label: 'Knowledge Source', + type: 'string', + choices: { project: 'Project', media: 'Media File' }, + default: 'project', + required: true, + altersDynamicFields: true, + }, + { ...projectField(false), helpText: 'The project to add (when source is Project).' }, + { + key: 'media_id', + label: 'Media ID', + type: 'string', + required: false, + helpText: 'The media file ID to add (when source is Media File).', + }, + ], + perform, + sample: { agentId: 'agent-123', added: true }, + outputFields: [{ key: 'agentId' }, { key: 'added', type: 'boolean' }], + }, +}; diff --git a/src/creates/assignTask.ts b/src/creates/assignTask.ts new file mode 100644 index 0000000..00c9346 --- /dev/null +++ b/src/creates/assignTask.ts @@ -0,0 +1,48 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField, taskField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const raw = bundle.inputData.member_ids; + const handles = (Array.isArray(raw) ? raw : [raw]).filter(Boolean); + + const response = await request(z, bundle, { + method: 'PUT', + path: `/projects/${bundle.inputData.project_id}/tasks/${bundle.inputData.task_id}/assignees`, + body: { handles }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { id: bundle.inputData.task_id, assignees: handles }; +}; + +export default { + key: 'assign_task', + noun: 'Task Assignment', + display: { + label: 'Assign Task', + description: 'Sets the assignees of an existing task.', + hidden: false, + }, + operation: { + inputFields: [ + spaceField(false), + projectField(true), + taskField('task_id', 'Task', true), + { + key: 'member_ids', + label: 'Assign To', + type: 'string', + dynamic: 'get_all_assignable_members.id.displayName', + required: true, + list: true, + altersDynamicFields: false, + }, + ], + perform, + sample: { id: '099630d4-267e-4b22-894b-08b69f3a4d79', assignees: ['janedoe'] }, + outputFields: [{ key: 'id', label: 'Task ID' }], + }, +}; diff --git a/src/creates/completeProject.ts b/src/creates/completeProject.ts new file mode 100644 index 0000000..6144053 --- /dev/null +++ b/src/creates/completeProject.ts @@ -0,0 +1,49 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const completed = bundle.inputData.completed; + const action = completed === false || completed === 'false' ? 'restore' : 'complete'; + + const response = await request(z, bundle, { + method: 'POST', + path: `/projects/${bundle.inputData.project_id}/${action}`, + body: {}, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { id: bundle.inputData.project_id, completed: action === 'complete' }; +}; + +export default { + key: 'complete_project', + noun: 'Project', + display: { + label: 'Complete Project', + description: 'Marks a project as completed (archived), or restores it.', + hidden: false, + }, + operation: { + inputFields: [ + spaceField(false), + projectField(true), + { + key: 'completed', + label: 'Mark as Complete', + type: 'boolean', + default: 'true', + required: false, + helpText: 'Set to No to restore a completed project instead.', + }, + ], + perform, + sample: { id: 'A1b2C3d4E5f6G7h8', completed: true }, + outputFields: [ + { key: 'id', label: 'Project ID' }, + { key: 'completed', type: 'boolean' }, + ], + }, +}; diff --git a/src/creates/copyProject.ts b/src/creates/copyProject.ts new file mode 100644 index 0000000..81ce45a --- /dev/null +++ b/src/creates/copyProject.ts @@ -0,0 +1,59 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const body: Record = { folderId: bundle.inputData.target_folder_id }; + if (bundle.inputData.project_title != null && bundle.inputData.project_title !== '') { + body.projectTitle = bundle.inputData.project_title; + } + + const response = await request(z, bundle, { + method: 'POST', + path: `/projects/${bundle.inputData.project_id}/copy`, + body, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? data; +}; + +export default { + key: 'copy_project', + noun: 'Project', + display: { + label: 'Copy Project', + description: 'Copies a project into a folder, optionally with a new title.', + hidden: false, + }, + operation: { + inputFields: [ + spaceField(false), + projectField(true), + { + key: 'target_folder_id', + label: 'Destination Folder', + type: 'string', + dynamic: 'get_all_spaces.id.name', + required: true, + list: false, + altersDynamicFields: false, + }, + { + key: 'project_title', + label: 'New Title', + type: 'string', + required: false, + helpText: 'Optional title for the copy. Defaults to the original name.', + }, + ], + perform, + sample: { id: 'Z9y8X7w6V5u4T3s2', name: 'Q3 Launch Plan (Copy)' }, + outputFields: [ + { key: 'id', label: 'Project ID' }, + { key: 'name', label: 'Name' }, + ], + }, +}; diff --git a/src/creates/createAgent.ts b/src/creates/createAgent.ts new file mode 100644 index 0000000..3f84645 --- /dev/null +++ b/src/creates/createAgent.ts @@ -0,0 +1,67 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { spaceField } from '../fields'; + +const PERSONAS = [ + 'Tasker', 'Researcher', 'Marketer', 'EmailWriter', 'Sales', 'CustomerSupport', + 'ProjectManager', 'ContentCreator', 'Copywriter', 'SeoSpecialist', 'Translator', + 'Summarizer', 'SocialMediaSpecialist', 'FinancialAnalyst', 'DataAnalyst', 'Editor', + 'BlogExpert', 'CourseCreator', 'Proofreader', 'StartupMentor', 'PromptEngineer', + 'ArticleWriter', 'LeadQualifier', 'ReviewResponder', +]; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'POST', + path: `/folders/${bundle.inputData.space_id}/agents`, + body: { + name: bundle.inputData.name as string, + data: { + type: 'template', + template: { type: bundle.inputData.persona as string }, + }, + }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? data; +}; + +export default { + key: 'create_agent', + noun: 'Agent', + display: { + label: 'Create AI Agent', + description: 'Creates a new AI agent from a persona template in a folder.', + hidden: false, + }, + operation: { + inputFields: [ + { ...spaceField(true), label: 'Folder' }, + { + key: 'name', + label: 'Agent Name', + type: 'string', + required: true, + }, + { + key: 'persona', + label: 'Persona', + type: 'string', + choices: Object.fromEntries(PERSONAS.map((p) => [p, p])), + default: 'Tasker', + required: true, + helpText: + 'The agent template to start from. Any valid Taskade persona works — this list shows common ones.', + }, + ], + perform, + sample: { id: 'agent-123', name: 'Research Assistant' }, + outputFields: [ + { key: 'id', label: 'Agent ID' }, + { key: 'name', label: 'Name' }, + ], + }, +}; diff --git a/src/creates/enableShareLink.ts b/src/creates/enableShareLink.ts new file mode 100644 index 0000000..b96903e --- /dev/null +++ b/src/creates/enableShareLink.ts @@ -0,0 +1,42 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'PUT', + path: `/projects/${bundle.inputData.project_id}/shareLink`, + body: {}, + }); + + const data: ApiResponse = parseOk(z, response.json); + + // API returns { viewUrl, editUrl, checkUrl? }; expose viewUrl as the primary link. + const item = (data.item ?? {}) as Record; + return { shareLink: item.viewUrl ?? null, ...item }; +}; + +export default { + key: 'enable_share_link', + noun: 'Share Link', + display: { + label: 'Get Project Share Link', + description: 'Enables sharing on a project and returns its shareable URL.', + hidden: false, + }, + operation: { + inputFields: [spaceField(false), projectField(true)], + perform, + sample: { + shareLink: 'https://www.taskade.com/d/A1b2C3d4E5f6G7h8', + viewUrl: 'https://www.taskade.com/d/A1b2C3d4E5f6G7h8', + editUrl: 'https://www.taskade.com/d/A1b2C3d4E5f6G7h8?edit=1', + }, + outputFields: [ + { key: 'shareLink', label: 'Share URL (View)' }, + { key: 'viewUrl', label: 'View URL' }, + { key: 'editUrl', label: 'Edit URL' }, + ], + }, +}; diff --git a/src/creates/generateAgent.ts b/src/creates/generateAgent.ts new file mode 100644 index 0000000..d970f58 --- /dev/null +++ b/src/creates/generateAgent.ts @@ -0,0 +1,44 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'POST', + path: `/folders/${bundle.inputData.space_id}/agent-generate`, + body: { text: bundle.inputData.text as string }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? data; +}; + +export default { + key: 'generate_agent', + noun: 'Agent', + display: { + label: 'Generate AI Agent', + description: 'Generates a new AI agent from a plain-language description.', + hidden: false, + }, + operation: { + inputFields: [ + { ...spaceField(true), label: 'Folder' }, + { + key: 'text', + label: 'Description', + type: 'text', + required: true, + helpText: 'Describe the agent you want, e.g. "An agent that triages inbound support emails".', + }, + ], + perform, + sample: { id: 'agent-123', name: 'Support Triage Agent' }, + outputFields: [ + { key: 'id', label: 'Agent ID' }, + { key: 'name', label: 'Name' }, + ], + }, +}; diff --git a/src/creates/publishAgent.ts b/src/creates/publishAgent.ts new file mode 100644 index 0000000..73eb62d --- /dev/null +++ b/src/creates/publishAgent.ts @@ -0,0 +1,43 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'PUT', + path: `/agents/${bundle.inputData.agent_id}/publicAccess`, + body: {}, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? data; +}; + +export default { + key: 'publish_agent', + noun: 'Agent', + display: { + label: 'Enable Public Agent Access', + description: 'Makes an AI agent publicly accessible and returns its public URL.', + hidden: false, + }, + operation: { + inputFields: [ + { ...spaceField(true), label: 'Folder', altersDynamicFields: true }, + { + key: 'agent_id', + label: 'Agent', + type: 'string', + dynamic: 'get_all_agents.id.title', + required: true, + list: false, + altersDynamicFields: false, + }, + ], + perform, + sample: { publicUrl: 'https://www.taskade.com/a/agent-123' }, + outputFields: [{ key: 'publicUrl', label: 'Public URL' }], + }, +}; diff --git a/src/creates/setCustomField.ts b/src/creates/setCustomField.ts new file mode 100644 index 0000000..953f30e --- /dev/null +++ b/src/creates/setCustomField.ts @@ -0,0 +1,60 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField, taskField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const raw = bundle.inputData.value as string; + const numeric = Number(raw); + const value = + raw !== '' && Number.isFinite(numeric) && String(numeric) === raw.trim() ? numeric : raw; + + const response = await request(z, bundle, { + method: 'PUT', + path: `/projects/${bundle.inputData.project_id}/tasks/${bundle.inputData.task_id}/fields/${bundle.inputData.field_id}`, + body: { value }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { id: bundle.inputData.task_id, fieldId: bundle.inputData.field_id }; +}; + +export default { + key: 'set_custom_field', + noun: 'Custom Field', + display: { + label: 'Set Task Custom Field', + description: 'Sets a custom field value on a task (table/board projects).', + hidden: false, + }, + operation: { + inputFields: [ + spaceField(false), + projectField(true), + taskField('task_id', 'Task', true), + { + key: 'field_id', + label: 'Field', + type: 'string', + dynamic: 'get_all_fields.id.title', + required: true, + list: false, + altersDynamicFields: false, + }, + { + key: 'value', + label: 'Value', + type: 'string', + required: true, + helpText: 'Numeric strings are sent as numbers automatically.', + }, + ], + perform, + sample: { id: '099630d4-267e-4b22-894b-08b69f3a4d79', fieldId: 'field-123' }, + outputFields: [ + { key: 'id', label: 'Task ID' }, + { key: 'fieldId', label: 'Field ID' }, + ], + }, +}; diff --git a/src/creates/setTaskDate.ts b/src/creates/setTaskDate.ts new file mode 100644 index 0000000..4ea6614 --- /dev/null +++ b/src/creates/setTaskDate.ts @@ -0,0 +1,79 @@ +import moment from 'moment-timezone'; +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField, taskField } from '../fields'; + +const toDatePart = (value: string, timezone: string) => { + const m = moment.tz(value, timezone); + return { date: m.format('YYYY-MM-DD'), time: m.format('HH:mm:ss'), timezone }; +}; + +const perform = async (z: ZObject, bundle: Bundle) => { + const basePath = `/projects/${bundle.inputData.project_id}/tasks/${bundle.inputData.task_id}/date`; + const clear = bundle.inputData.clear === true || bundle.inputData.clear === 'true'; + + if (clear) { + const response = await request(z, bundle, { method: 'DELETE', path: basePath }); + parseOk(z, response.json); + return { id: bundle.inputData.task_id as string, cleared: true }; + } + + // Guard: moment(undefined) silently means "now" — never write an implicit date. + if (bundle.inputData.start_date == null || bundle.inputData.start_date === '') { + throw new z.errors.Error( + 'Start Date is required unless Clear Due Date is Yes.', + 'invalid_input', + 400, + ); + } + + const timezone = bundle.meta.timezone ?? 'Etc/UTC'; + const body: Record = { + start: toDatePart(bundle.inputData.start_date as string, timezone), + }; + if (bundle.inputData.end_date != null && bundle.inputData.end_date !== '') { + body.end = toDatePart(bundle.inputData.end_date as string, timezone); + } + + const response = await request(z, bundle, { method: 'PUT', path: basePath, body }); + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { id: bundle.inputData.task_id }; +}; + +export default { + key: 'set_task_date', + noun: 'Due Date', + display: { + label: 'Set Task Due Date', + description: 'Sets (or clears) the due date of an existing task.', + hidden: false, + }, + operation: { + inputFields: [ + spaceField(false), + projectField(true), + taskField('task_id', 'Task', true), + { + key: 'start_date', + label: 'Start Date', + type: 'datetime', + required: false, + helpText: 'Required unless clearing the date.', + }, + { key: 'end_date', label: 'End Date', type: 'datetime', required: false }, + { + key: 'clear', + label: 'Clear Due Date', + type: 'boolean', + default: 'false', + required: false, + helpText: 'Set to Yes to remove the due date instead of setting one.', + }, + ], + perform, + sample: { id: '099630d4-267e-4b22-894b-08b69f3a4d79' }, + outputFields: [{ key: 'id', label: 'Task ID' }], + }, +}; diff --git a/src/creates/setTaskNote.ts b/src/creates/setTaskNote.ts new file mode 100644 index 0000000..228c8f6 --- /dev/null +++ b/src/creates/setTaskNote.ts @@ -0,0 +1,54 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField, taskField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'PUT', + path: `/projects/${bundle.inputData.project_id}/tasks/${bundle.inputData.task_id}/note`, + body: { + type: (bundle.inputData.note_type as string) ?? 'text/markdown', + value: bundle.inputData.value as string, + }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { id: bundle.inputData.task_id }; +}; + +export default { + key: 'set_task_note', + noun: 'Task Note', + display: { + label: 'Set Task Note', + description: 'Sets the note of an existing task (single-line).', + hidden: false, + }, + operation: { + inputFields: [ + spaceField(false), + projectField(true), + taskField('task_id', 'Task', true), + { + key: 'value', + label: 'Note', + type: 'string', + required: true, + helpText: 'Note content — single line, no line breaks.', + }, + { + key: 'note_type', + label: 'Format', + type: 'string', + choices: { 'text/markdown': 'Markdown', 'text/plain': 'Plain text' }, + default: 'text/markdown', + required: false, + }, + ], + perform, + sample: { id: '099630d4-267e-4b22-894b-08b69f3a4d79' }, + outputFields: [{ key: 'id', label: 'Task ID' }], + }, +}; diff --git a/src/creates/triggerAutomation.ts b/src/creates/triggerAutomation.ts new file mode 100644 index 0000000..bcc2fd9 --- /dev/null +++ b/src/creates/triggerAutomation.ts @@ -0,0 +1,71 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const url = bundle.inputData.webhook_url as string; + if (!url.startsWith('https://')) { + throw new z.errors.Error('The automation webhook URL must use https.', 'invalid_input', 400); + } + + let payload: unknown = {}; + if (bundle.inputData.payload) { + try { + payload = JSON.parse(bundle.inputData.payload as string); + } catch { + throw new z.errors.Error('Payload must be valid JSON.', 'invalid_input', 400); + } + } + + const response = await z.request({ + url, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + skipThrowForStatus: true, + }); + + if (response.status >= 400) { + throw new z.errors.Error( + `Automation webhook returned ${response.status} — check the webhook URL is current.`, + 'invalid_input', + response.status, + ); + } + + return { status: response.status, ok: true }; +}; + +export default { + key: 'trigger_automation', + noun: 'Automation Run', + display: { + label: 'Trigger Taskade Automation', + description: + 'Starts a Taskade automation that uses a Webhook trigger, passing an optional JSON payload.', + hidden: false, + }, + operation: { + inputFields: [ + { + key: 'webhook_url', + label: 'Automation Webhook URL', + type: 'string', + required: true, + helpText: + 'In your Taskade automation, add a **Webhook** trigger and paste its unique URL here. Payload fields become `{{trigger.}}` variables in the flow.', + }, + { + key: 'payload', + label: 'JSON Payload', + type: 'text', + required: false, + helpText: 'Optional JSON object to send, e.g. `{"customer": "Jane", "amount": 100}`.', + }, + ], + perform, + sample: { status: 200, ok: true }, + outputFields: [ + { key: 'status', label: 'HTTP Status', type: 'integer' }, + { key: 'ok', type: 'boolean' }, + ], + }, +}; diff --git a/src/creates/updateAgent.ts b/src/creates/updateAgent.ts new file mode 100644 index 0000000..08a2427 --- /dev/null +++ b/src/creates/updateAgent.ts @@ -0,0 +1,46 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { spaceField } from '../fields'; + +const agentDropdown = { + key: 'agent_id', + label: 'Agent', + type: 'string', + dynamic: 'get_all_agents.id.title', + required: true, + list: false, + altersDynamicFields: false, +}; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'PATCH', + path: `/agents/${bundle.inputData.agent_id}`, + body: { name: bundle.inputData.name as string }, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return data.item ?? { id: bundle.inputData.agent_id }; +}; + +export default { + key: 'update_agent', + noun: 'Agent', + display: { + label: 'Update AI Agent', + description: "Renames an AI agent.", + hidden: false, + }, + operation: { + inputFields: [ + { ...spaceField(true), label: 'Folder', altersDynamicFields: true }, + agentDropdown, + { key: 'name', label: 'New Name', type: 'string', required: true }, + ], + perform, + sample: { id: 'agent-123', name: 'Renamed Agent' }, + outputFields: [{ key: 'id', label: 'Agent ID' }], + }, +}; diff --git a/src/index.ts b/src/index.ts index 9cdcd39..e293d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,23 +3,45 @@ import 'cross-fetch/polyfill'; import { version as platformVersion } from 'zapier-platform-core'; import { authentication } from './authentication'; +import AddAgentKnowledge from './creates/addAgentKnowledge'; +import AssignTask from './creates/assignTask'; +import CompleteProject from './creates/completeProject'; import CompleteTask from './creates/completeTask'; +import CopyProject from './creates/copyProject'; +import CreateAgent from './creates/createAgent'; import CreateProject from './creates/createProject'; import CreateProjectFromTemplate from './creates/createProjectFromTemplate'; import CreateTask from './creates/createTask'; import CustomApiCall from './creates/customApiCall'; import DeleteTask from './creates/deleteTask'; +import EnableShareLink from './creates/enableShareLink'; +import GenerateAgent from './creates/generateAgent'; import MoveTask from './creates/moveTask'; +import PublishAgent from './creates/publishAgent'; import RunAgent from './creates/runAgent'; +import SetCustomField from './creates/setCustomField'; +import SetTaskDate from './creates/setTaskDate'; +import SetTaskNote from './creates/setTaskNote'; +import TriggerAutomation from './creates/triggerAutomation'; +import UpdateAgent from './creates/updateAgent'; import UpdateTask from './creates/updateTask'; import FindProject from './searches/findProject'; import FindTask from './searches/findTask'; +import GetAllAgents from './triggers/getAllAgents'; import GetAllAssignableMembers from './triggers/getAllAssignableMembers'; import GetAllBlocks from './triggers/getAllBlocks'; +import GetAllFields from './triggers/getAllFields'; import GetAllProjects from './triggers/getAllProjects'; import GetAllProjectTemplates from './triggers/getAllProjectTemplates'; import GetAllSpaces from './triggers/getAllSpaces'; import GetAllTasks from './triggers/getAllTasks'; +import { + NewComment, + NewProject, + ProjectAssigned, + ProjectJoined, + TaskAssigned, +} from './triggers/publicWebhook'; import TaskDue from './triggers/taskDue'; const { version } = require('../package.json'); @@ -30,24 +52,50 @@ export default { authentication, triggers: { + // hidden dropdown helpers [GetAllBlocks.key]: GetAllBlocks, [GetAllProjects.key]: GetAllProjects, [GetAllSpaces.key]: GetAllSpaces, [GetAllAssignableMembers.key]: GetAllAssignableMembers, [GetAllTasks.key]: GetAllTasks, [GetAllProjectTemplates.key]: GetAllProjectTemplates, + [GetAllAgents.key]: GetAllAgents, + [GetAllFields.key]: GetAllFields, + // instant triggers [TaskDue.key]: TaskDue, + [NewComment.key]: NewComment, + [TaskAssigned.key]: TaskAssigned, + [NewProject.key]: NewProject, + [ProjectAssigned.key]: ProjectAssigned, + [ProjectJoined.key]: ProjectJoined, }, creates: { + // tasks [CreateTask.key]: CreateTask, [CompleteTask.key]: CompleteTask, [UpdateTask.key]: UpdateTask, [DeleteTask.key]: DeleteTask, [MoveTask.key]: MoveTask, + [SetTaskDate.key]: SetTaskDate, + [AssignTask.key]: AssignTask, + [SetTaskNote.key]: SetTaskNote, + [SetCustomField.key]: SetCustomField, + // projects [CreateProject.key]: CreateProject, [CreateProjectFromTemplate.key]: CreateProjectFromTemplate, + [CompleteProject.key]: CompleteProject, + [CopyProject.key]: CopyProject, + [EnableShareLink.key]: EnableShareLink, + // AI agents [RunAgent.key]: RunAgent, + [CreateAgent.key]: CreateAgent, + [GenerateAgent.key]: GenerateAgent, + [UpdateAgent.key]: UpdateAgent, + [AddAgentKnowledge.key]: AddAgentKnowledge, + [PublishAgent.key]: PublishAgent, + // automations + escape hatch + [TriggerAutomation.key]: TriggerAutomation, [CustomApiCall.key]: CustomApiCall, }, diff --git a/src/test/app.test.ts b/src/test/app.test.ts index 269c28e..d208623 100644 --- a/src/test/app.test.ts +++ b/src/test/app.test.ts @@ -38,14 +38,39 @@ describe('Taskade app definition', () => { 'update_task', 'delete_task', 'move_task', + 'set_task_date', + 'assign_task', + 'set_task_note', + 'set_custom_field', 'create_project', 'create_project_from_template', + 'complete_project', + 'copy_project', + 'enable_share_link', 'run_agent', + 'create_agent', + 'generate_agent', + 'update_agent', + 'add_agent_knowledge', + 'publish_agent', + 'trigger_automation', 'custom_api_call', ]), ); }); + it('registers the public-webhook instant triggers', () => { + expect(Object.keys(App.triggers)).toEqual( + expect.arrayContaining([ + 'new_comment', + 'task_assigned', + 'new_project', + 'project_assigned', + 'project_joined', + ]), + ); + }); + it('registers the expected searches', () => { expect(Object.keys(App.searches)).toEqual( expect.arrayContaining(['find_task', 'find_project']), diff --git a/src/triggers/getAllAgents.ts b/src/triggers/getAllAgents.ts new file mode 100644 index 0000000..edb9b99 --- /dev/null +++ b/src/triggers/getAllAgents.ts @@ -0,0 +1,37 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'GET', + path: `/folders/${bundle.inputData.space_id}/agents`, + }); + + const data: ApiResponse = parseOk(z, response.json); + + return (data.items ?? []).map((agent) => ({ + id: agent.id as string, + title: (agent.name ?? agent.id) as string, + })); +}; + +export default { + operation: { + perform, + inputFields: [{ ...spaceField(true), altersDynamicFields: true }], + sample: { id: 'agent-123', title: 'Research Assistant' }, + outputFields: [ + { key: 'id', label: 'ID' }, + { key: 'title', label: 'Name' }, + ], + }, + key: 'get_all_agents', + noun: 'Agents', + display: { + label: 'Get All Agents', + description: 'List all AI agents in a folder.', + hidden: true, + }, +}; diff --git a/src/triggers/getAllFields.ts b/src/triggers/getAllFields.ts new file mode 100644 index 0000000..6aba96b --- /dev/null +++ b/src/triggers/getAllFields.ts @@ -0,0 +1,42 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; +import { projectField, spaceField } from '../fields'; + +const perform = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'GET', + path: `/projects/${bundle.inputData.project_id}/fields`, + }); + + const data: ApiResponse = parseOk(z, response.json); + + // The human label lives inside field.data (displayName for most field + // types, title for rating/AI variants) — not at the top level. + return (data.items ?? []).map((field) => { + const fieldData = (field.data ?? {}) as Record; + return { + id: field.id as string, + title: (fieldData.displayName ?? fieldData.title ?? field.id) as string, + }; + }); +}; + +export default { + operation: { + perform, + inputFields: [spaceField(false), projectField(true)], + sample: { id: 'field-123', title: 'Priority' }, + outputFields: [ + { key: 'id', label: 'ID' }, + { key: 'title', label: 'Name' }, + ], + }, + key: 'get_all_fields', + noun: 'Custom Fields', + display: { + label: 'Get All Custom Fields', + description: 'List all custom fields in a project.', + hidden: true, + }, +}; diff --git a/src/triggers/publicWebhook.ts b/src/triggers/publicWebhook.ts new file mode 100644 index 0000000..386b5e1 --- /dev/null +++ b/src/triggers/publicWebhook.ts @@ -0,0 +1,199 @@ +import { Bundle, ZObject } from 'zapier-platform-core'; + +import { ApiResponse, parseOk, request } from '../client'; + +/** + * Factory for instant triggers backed by Taskade's public webhook-subscription + * API (v2 POST /subscribeWebhook + /unsubscribeWebhook). One definition per + * public trigger type; the delivered payload is the typed packet the server + * renders for that event. + * + * Note: the legacy `task_due` trigger predates this API and stays on its + * original endpoints; new triggers all use the public surface below. + */ +interface PublicWebhookTriggerOptions { + key: string; + noun: string; + label: string; + description: string; + /** Public trigger type accepted by POST /subscribeWebhook. */ + triggerType: string; + sample: Record; + outputFields: Array>; +} + +export const makePublicWebhookTrigger = (options: PublicWebhookTriggerOptions) => { + const performSubscribe = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'POST', + path: '/subscribeWebhook', + version: 'v2', + body: { targetUrl: bundle.targetUrl, triggerType: options.triggerType }, + }); + const data: ApiResponse = parseOk(z, response.json); + return data; // { ok: true, hookId } -> stored as bundle.subscribeData + }; + + const performUnsubscribe = async (z: ZObject, bundle: Bundle) => { + const response = await request(z, bundle, { + method: 'POST', + path: '/unsubscribeWebhook', + version: 'v2', + // @ts-ignore subscribeData is populated by Zapier with performSubscribe's result + body: { hookId: bundle.subscribeData.hookId }, + }); + return parseOk(z, response.json); + }; + + const perform = async (z: ZObject, bundle: Bundle) => [bundle.cleanedRequest]; + + // No public list endpoint exists for historical events; return the + // documented sample so users can set up their Zap before the first delivery. + const performList = async () => [options.sample]; + + return { + key: options.key, + noun: options.noun, + display: { + label: options.label, + description: options.description, + hidden: false, + }, + operation: { + type: 'hook', + perform, + performSubscribe, + performUnsubscribe, + performList, + sample: options.sample, + outputFields: options.outputFields, + }, + }; +}; + +// ---- packet shapes below mirror the server's typed Zapier packets ---- + +export const NewComment = makePublicWebhookTrigger({ + key: 'new_comment', + noun: 'Comment', + label: 'New Comment', + description: 'Triggers when a comment is added to a task.', + triggerType: 'comment.created', + sample: { + projectName: 'Customer Projects', + projectId: 'A1b2C3d4E5f6G7h8', + nodeId: '099630d4-267e-4b22-894b-08b69f3a4d79', + nodeText: 'Follow up with the client', + commenterDisplayName: 'Jane Doe', + commenterHandle: 'janedoe', + commentBody: 'Done — sent the proposal.', + commentBodyType: 'text/markdown', + assignees: [], + mentionedHandles: [], + }, + outputFields: [ + { key: 'projectName' }, + { key: 'projectId' }, + { key: 'nodeId', label: 'Task ID' }, + { key: 'nodeText', label: 'Task Text' }, + { key: 'commenterDisplayName' }, + { key: 'commenterHandle' }, + { key: 'commentBody' }, + { key: 'commentBodyType' }, + ], +}); + +export const TaskAssigned = makePublicWebhookTrigger({ + key: 'task_assigned', + noun: 'Task Assignment', + label: 'Task Assigned', + description: 'Triggers when tasks are assigned to someone.', + triggerType: 'task.assigned', + sample: { + projectName: 'Customer Projects', + projectId: 'A1b2C3d4E5f6G7h8', + assignerName: 'Jane Doe', + nodes: [ + { + nodeId: '099630d4-267e-4b22-894b-08b69f3a4d79', + nodeText: 'Prepare the quote', + isCompleted: false, + assignees: ['johnsmith'], + }, + ], + }, + outputFields: [ + { key: 'projectName' }, + { key: 'projectId' }, + { key: 'assignerName' }, + { key: 'nodes[]nodeId', label: 'Task ID' }, + { key: 'nodes[]nodeText', label: 'Task Text' }, + { key: 'nodes[]isCompleted', type: 'boolean' }, + ], +}); + +export const NewProject = makePublicWebhookTrigger({ + key: 'new_project', + noun: 'Project', + label: 'New Project', + description: 'Triggers when a project is created.', + triggerType: 'project.created', + sample: { + spaceName: 'Acme Workspace', + spaceId: '1UsRFaZu9XgyTFej', + projectName: 'Q3 Launch Plan', + projectId: 'A1b2C3d4E5f6G7h8', + creatorName: 'Jane Doe', + }, + outputFields: [ + { key: 'spaceName' }, + { key: 'spaceId' }, + { key: 'projectName' }, + { key: 'projectId' }, + { key: 'creatorName' }, + ], +}); + +export const ProjectAssigned = makePublicWebhookTrigger({ + key: 'project_assigned', + noun: 'Project Assignment', + label: 'Project Assigned', + description: 'Triggers when a project is assigned to someone.', + triggerType: 'project.assigned', + sample: { + spaceName: 'Acme Workspace', + spaceId: '1UsRFaZu9XgyTFej', + projectName: 'Q3 Launch Plan', + projectId: 'A1b2C3d4E5f6G7h8', + assignerName: 'Jane Doe', + assigneeName: 'John Smith', + assigneeId: 12345, + }, + outputFields: [ + { key: 'spaceName' }, + { key: 'projectName' }, + { key: 'projectId' }, + { key: 'assignerName' }, + { key: 'assigneeName' }, + ], +}); + +export const ProjectJoined = makePublicWebhookTrigger({ + key: 'project_joined', + noun: 'Project Member', + label: 'Member Joined Project', + description: 'Triggers when someone joins a project.', + triggerType: 'project.joined', + sample: { + spaceId: '1UsRFaZu9XgyTFej', + projectName: 'Q3 Launch Plan', + projectId: 'A1b2C3d4E5f6G7h8', + joinerName: 'John Smith', + joinerUserId: 12345, + }, + outputFields: [ + { key: 'projectName' }, + { key: 'projectId' }, + { key: 'joinerName' }, + ], +});