diff --git a/bun.lock b/bun.lock index f0fc9471100e..4e12133c35bc 100644 --- a/bun.lock +++ b/bun.lock @@ -752,7 +752,7 @@ }, "packages/pieces/community/asana": { "name": "@activepieces/piece-asana", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2746,7 +2746,7 @@ }, "packages/pieces/community/dropbox": { "name": "@activepieces/piece-dropbox", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2755,6 +2755,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/drupal": { @@ -4556,7 +4557,7 @@ }, "packages/pieces/community/instagram-business": { "name": "@activepieces/piece-instagram-business", - "version": "0.2.7", + "version": "1.0.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 976ecd85ce24..c700014f177b 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -8,6 +8,20 @@ icon: "hammer" ### What has changed? +#### Instagram for Business no longer reports success when a reel fails to publish + +Publishing a reel uploads the video to Instagram and then waits for Instagram to finish processing it before the post goes live. The piece used to give up after about a hundred seconds and return the boolean `false`, which the flow recorded as a **successful** step. Anything downstream carried on with `false` where a media id was expected, and nothing in the run said the post had not been published. + +Upload Reel now throws when Instagram reports an error, and waits up to five minutes, which is the window Instagram's own guidance describes. Upload Photo waits for the same signal instead of publishing blind, so a container Instagram rejects now surfaces as a failed step rather than a confusing error from the next call. + +The piece also moves from Graph API v17.0, which expired on 12 September 2025, to v23.0. + +#### What you need to do + +If a flow branches on the output of Upload Reel being `false`, that branch will no longer be taken — the step fails instead. Handle the failure with the step's error handling options, or with a branch on the run status. + +Reconnect your Instagram connections. The piece now requests the permissions needed for comments, insights, content management, direct messages and Page metadata, and a token issued before this release does not carry them. Steps that were saved earlier will also ask you to select the Page again, because messaging needs the Facebook Page id that the Page dropdown now stores. + #### The AI provider config endpoint is removed `GET /v1/ai-providers/:provider/config` returned a provider's decrypted credentials so that an AI step running inside the engine could build the model itself and call the provider directly. AI steps no longer work that way: the worker resolves the credentials and makes the call, which is what lets Activepieces charge managed AI on what the call actually cost. Nothing in the product calls the endpoint any more, and it now returns `404`. diff --git a/packages/pieces/community/asana/package.json b/packages/pieces/community/asana/package.json index 696f76e9b16e..07d3fc9534a7 100644 --- a/packages/pieces/community/asana/package.json +++ b/packages/pieces/community/asana/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-asana", - "version": "0.5.0", + "version": "0.6.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/asana/src/i18n/translation.json b/packages/pieces/community/asana/src/i18n/translation.json index 3cdb14d17fe9..0560c099af5a 100644 --- a/packages/pieces/community/asana/src/i18n/translation.json +++ b/packages/pieces/community/asana/src/i18n/translation.json @@ -38,5 +38,414 @@ "None": "None", "JSON": "JSON", "Form Data": "Form Data", - "Raw": "Raw" + "Raw": "Raw", + "Get Current User": "Get Current User", + "List Workspaces": "List Workspaces", + "List Teams": "List Teams", + "Search Workspace Objects": "Search Workspace Objects", + "Create Project Task": "Create Project Task", + "Get Task": "Get Task", + "Update Task": "Update Task", + "Delete Task": "Delete Task", + "Duplicate Task": "Duplicate Task", + "Get Job": "Get Job", + "List Assigned Tasks": "List Assigned Tasks", + "List Project Tasks": "List Project Tasks", + "List Subtasks": "List Subtasks", + "Create Subtask": "Create Subtask", + "Set Task Parent": "Set Task Parent", + "List Task Projects": "List Task Projects", + "Add Task to Project": "Add Task to Project", + "Remove Task from Project": "Remove Task from Project", + "Add Task Followers": "Add Task Followers", + "Remove Task Followers": "Remove Task Followers", + "Add Task Comment": "Add Task Comment", + "List Task Stories": "List Task Stories", + "Get Story": "Get Story", + "Update Comment": "Update Comment", + "Delete Comment": "Delete Comment", + "Create Project": "Create Project", + "Get Project": "Get Project", + "List Projects": "List Projects", + "Update Project": "Update Project", + "Delete Project": "Delete Project", + "Duplicate Project": "Duplicate Project", + "Get Project Task Counts": "Get Project Task Counts", + "Add Project Members": "Add Project Members", + "Remove Project Members": "Remove Project Members", + "Add Project Followers": "Add Project Followers", + "Remove Project Followers": "Remove Project Followers", + "Create Section": "Create Section", + "Get Section": "Get Section", + "List Sections": "List Sections", + "Rename Section": "Rename Section", + "Delete Section": "Delete Section", + "Move Section": "Move Section", + "Move Task to Section": "Move Task to Section", + "List Section Tasks": "List Section Tasks", + "Create Membership": "Create Membership", + "Get Membership": "Get Membership", + "List Memberships": "List Memberships", + "Delete Membership": "Delete Membership", + "Create Status Update": "Create Status Update", + "Get Status Update": "Get Status Update", + "List Status Updates": "List Status Updates", + "Delete Status Update": "Delete Status Update", + "Create Project Brief": "Create Project Brief", + "Get Project Brief": "Get Project Brief", + "Update Project Brief": "Update Project Brief", + "Delete Project Brief": "Delete Project Brief", + "Create Tag": "Create Tag", + "Get Tag": "Get Tag", + "List Tags": "List Tags", + "Update Tag": "Update Tag", + "Delete Tag": "Delete Tag", + "List Task Tags": "List Task Tags", + "Add Tag to Task": "Add Tag to Task", + "Remove Tag from Task": "Remove Tag from Task", + "List Tag Tasks": "List Tag Tasks", + "Get the Asana user behind this connection.": "Get the Asana user behind this connection.", + "List the workspaces and organizations the connected user can access.": "List the workspaces and organizations the connected user can access.", + "List the teams in an Asana organization.": "List the teams in an Asana organization.", + "Find tasks, projects, users, tags or teams by name in a workspace.": "Find tasks, projects, users, tags or teams by name in a workspace.", + "Create a task in an Asana project.": "Create a task in an Asana project.", + "Get the details of an Asana task.": "Get the details of an Asana task.", + "Change fields of an existing Asana task.": "Change fields of an existing Asana task.", + "Delete an Asana task.": "Delete an Asana task.", + "Start copying an Asana task into a new task.": "Start copying an Asana task into a new task.", + "Check the status of an asynchronous Asana job.": "Check the status of an asynchronous Asana job.", + "List the tasks assigned to a user in a workspace.": "List the tasks assigned to a user in a workspace.", + "List the tasks in an Asana project.": "List the tasks in an Asana project.", + "List the subtasks of an Asana task.": "List the subtasks of an Asana task.", + "Create a subtask under an existing Asana task.": "Create a subtask under an existing Asana task.", + "Make a task a subtask of another task, or detach it from its parent.": "Make a task a subtask of another task, or detach it from its parent.", + "List the projects an Asana task belongs to.": "List the projects an Asana task belongs to.", + "Add an existing task to another Asana project.": "Add an existing task to another Asana project.", + "Remove a task from one of its Asana projects.": "Remove a task from one of its Asana projects.", + "Add followers (collaborators) to an Asana task.": "Add followers (collaborators) to an Asana task.", + "Remove followers from an Asana task.": "Remove followers from an Asana task.", + "Post a comment on an Asana task.": "Post a comment on an Asana task.", + "List the comments and activity of an Asana task.": "List the comments and activity of an Asana task.", + "Get one comment or activity entry of an Asana task.": "Get one comment or activity entry of an Asana task.", + "Edit or pin a comment on an Asana task.": "Edit or pin a comment on an Asana task.", + "Delete a comment from an Asana task.": "Delete a comment from an Asana task.", + "Create a new Asana project.": "Create a new Asana project.", + "Get the details of an Asana project.": "Get the details of an Asana project.", + "List the projects in an Asana workspace.": "List the projects in an Asana workspace.", + "Change fields of an existing Asana project.": "Change fields of an existing Asana project.", + "Delete an Asana project.": "Delete an Asana project.", + "Start copying an Asana project into a new project.": "Start copying an Asana project into a new project.", + "Get the number of total, completed and open tasks and milestones in a project.": "Get the number of total, completed and open tasks and milestones in a project.", + "Give users access to an Asana project.": "Give users access to an Asana project.", + "Revoke users' membership of an Asana project.": "Revoke users' membership of an Asana project.", + "Subscribe users to \"task added\" notifications of an Asana project.": "Subscribe users to \"task added\" notifications of an Asana project.", + "Unsubscribe users from \"task added\" notifications of an Asana project.": "Unsubscribe users from \"task added\" notifications of an Asana project.", + "Create a section (list heading or board column) in an Asana project.": "Create a section (list heading or board column) in an Asana project.", + "Get the details of an Asana section.": "Get the details of an Asana section.", + "List the sections of an Asana project.": "List the sections of an Asana project.", + "Rename an Asana section.": "Rename an Asana section.", + "Delete an empty section from an Asana project.": "Delete an empty section from an Asana project.", + "Reorder a section within its Asana project.": "Reorder a section within its Asana project.", + "Move a task into a section of an Asana project.": "Move a task into a section of an Asana project.", + "List the tasks in one section of an Asana project.": "List the tasks in one section of an Asana project.", + "Get one membership of a project, goal or portfolio.": "Get one membership of a project, goal or portfolio.", + "List who has access to a project, goal or portfolio, or which projects a team is in.": "List who has access to a project, goal or portfolio, or which projects a team is in.", + "Revoke a user's or team's access to a project, goal or portfolio.": "Revoke a user's or team's access to a project, goal or portfolio.", + "Post a status update on an Asana project, portfolio or goal.": "Post a status update on an Asana project, portfolio or goal.", + "Get one status update of an Asana project, portfolio or goal.": "Get one status update of an Asana project, portfolio or goal.", + "List the status updates of an Asana project, portfolio or goal.": "List the status updates of an Asana project, portfolio or goal.", + "Delete a status update from an Asana project, portfolio or goal.": "Delete a status update from an Asana project, portfolio or goal.", + "Create the brief (overview document) of an Asana project.": "Create the brief (overview document) of an Asana project.", + "Get the brief (overview document) of an Asana project.": "Get the brief (overview document) of an Asana project.", + "Change the title or body of an Asana project brief.": "Change the title or body of an Asana project brief.", + "Delete the brief of an Asana project.": "Delete the brief of an Asana project.", + "Create a tag in an Asana workspace.": "Create a tag in an Asana workspace.", + "Get the details of an Asana tag.": "Get the details of an Asana tag.", + "List the tags in an Asana workspace.": "List the tags in an Asana workspace.", + "Rename or recolor an Asana tag.": "Rename or recolor an Asana tag.", + "Delete an Asana tag.": "Delete an Asana tag.", + "List the tags on an Asana task.": "List the tags on an Asana task.", + "Apply a tag to an Asana task.": "Apply a tag to an Asana task.", + "Take a tag off an Asana task.": "Take a tag off an Asana task.", + "List the tasks that carry an Asana tag.": "List the tasks that carry an Asana tag.", + "Limit": "Limit", + "Offset": "Offset", + "Organization (Workspace) GID": "Organization (Workspace) GID", + "Workspace GID": "Workspace GID", + "Object Type": "Object Type", + "Query": "Query", + "Count": "Count", + "Project GID": "Project GID", + "Description": "Description", + "HTML Description": "HTML Description", + "Due Date and Time": "Due Date and Time", + "Start Date": "Start Date", + "Followers": "Followers", + "Task GID": "Task GID", + "Completed": "Completed", + "New Task Name": "New Task Name", + "Include": "Include", + "Job GID": "Job GID", + "Completed Since": "Completed Since", + "Modified Since": "Modified Since", + "Parent Task GID": "Parent Task GID", + "Subtask Name": "Subtask Name", + "Insert Before Subtask": "Insert Before Subtask", + "Insert After Subtask": "Insert After Subtask", + "Section GID": "Section GID", + "Insert Before Task": "Insert Before Task", + "Insert After Task": "Insert After Task", + "Text": "Text", + "HTML Text": "HTML Text", + "Pin Comment": "Pin Comment", + "Story GID": "Story GID", + "Comment (Story) GID": "Comment (Story) GID", + "Pinned": "Pinned", + "Team GID": "Team GID", + "Project Name": "Project Name", + "Color": "Color", + "Default View": "Default View", + "Privacy": "Privacy", + "Archived": "Archived", + "Owner": "Owner", + "New Project Name": "New Project Name", + "Members": "Members", + "Section Name": "Section Name", + "Insert Before Section": "Insert Before Section", + "Insert After Section": "Insert After Section", + "Before Section": "Before Section", + "After Section": "After Section", + "Parent GID": "Parent GID", + "Member GID": "Member GID", + "Access Level": "Access Level", + "Membership GID": "Membership GID", + "Status": "Status", + "Title": "Title", + "Status Update GID": "Status Update GID", + "Created Since": "Created Since", + "Project Brief GID": "Project Brief GID", + "Tag Name": "Tag Name", + "Tag GID": "Tag GID", + "Maximum number of workspaces to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of workspaces to return in this page, from 1 to 100. Defaults to 50.", + "Pagination token for the next page. Pass the `next_offset` value returned by the previous call of this same action; leave empty for the first page. Tokens expire after a while.": "Pagination token for the next page. Pass the `next_offset` value returned by the previous call of this same action; leave empty for the first page. Tokens expire after a while.", + "Gid of the organization, for example 1201234567890123. Obtain it from List Workspaces (is_organization must be true).": "Gid of the organization, for example 1201234567890123. Obtain it from List Workspaces (is_organization must be true).", + "Maximum number of teams to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of teams to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the workspace to search, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.": "Gid of the workspace to search, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.", + "Which kind of object to return.": "Which kind of object to return.", + "Text to match against object names, for example \"Q4 launch\". Leave empty to get recently relevant objects of this type.": "Text to match against object names, for example \"Q4 launch\". Leave empty to get recently relevant objects of this type.", + "Number of results to return, from 1 to 100. Defaults to 20.": "Number of results to return, from 1 to 100. Defaults to 20.", + "Gid of the workspace that owns the project, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.": "Gid of the workspace that owns the project, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.", + "Gid of the project to create the task in. Obtain it from List Projects or Search Workspace Objects (object type project).": "Gid of the project to create the task in. Obtain it from List Projects or Search Workspace Objects (object type project).", + "Short title of the task, for example \"Draft launch email\".": "Short title of the task, for example \"Draft launch email\".", + "Plain-text description of the task. Cannot be combined with HTML Description.": "Plain-text description of the task. Cannot be combined with HTML Description.", + "Rich-text description in Asana rich text, wrapped in ..., for example See brief. Cannot be combined with Description.": "Rich-text description in Asana rich text, wrapped in ..., for example See brief. Cannot be combined with Description.", + "User to assign: \"me\", an email address or a user gid.": "User to assign: \"me\", an email address or a user gid.", + "All-day due date in YYYY-MM-DD format, for example 2026-10-15. Cannot be combined with Due Date and Time.": "All-day due date in YYYY-MM-DD format, for example 2026-10-15. Cannot be combined with Due Date and Time.", + "Start date in YYYY-MM-DD format. Asana requires a due date or due time when a start date is set.": "Start date in YYYY-MM-DD format. Asana requires a due date or due time when a start date is set.", + "Users to add as followers, one per item: \"me\", an email address or a user gid.": "Users to add as followers, one per item: \"me\", an email address or a user gid.", + "Gid of the task, for example 1204567890123456. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.": "Gid of the task, for example 1204567890123456. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.", + "Gid of the task to update. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.": "Gid of the task to update. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.", + "New title of the task. Leave empty to keep the current name.": "New title of the task. Leave empty to keep the current name.", + "New plain-text description; replaces the whole current description. Cannot be combined with HTML Description. Leave empty to keep it.": "New plain-text description; replaces the whole current description. Cannot be combined with HTML Description. Leave empty to keep it.", + "New rich-text description wrapped in ...; replaces the whole current description. Cannot be combined with Description.": "New rich-text description wrapped in ...; replaces the whole current description. Cannot be combined with Description.", + "New assignee: \"me\", an email address or a user gid. Leave empty to keep the current assignee.": "New assignee: \"me\", an email address or a user gid. Leave empty to keep the current assignee.", + "New all-day due date in YYYY-MM-DD format. Cannot be combined with Due Date and Time.": "New all-day due date in YYYY-MM-DD format. Cannot be combined with Due Date and Time.", + "New start date in YYYY-MM-DD format. The task must have (or get) a due date.": "New start date in YYYY-MM-DD format. The task must have (or get) a due date.", + "Yes marks the task complete, No reopens it. Leave empty to keep the current state.": "Yes marks the task complete, No reopens it. Leave empty to keep the current state.", + "Gid of the task to delete. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.": "Gid of the task to delete. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.", + "Gid of the task to copy. Obtain it from List Project Tasks or Search Workspace Objects.": "Gid of the task to copy. Obtain it from List Project Tasks or Search Workspace Objects.", + "Name of the new task, for example \"Copy of Draft launch email\".": "Name of the new task, for example \"Copy of Draft launch email\".", + "Parts of the original task to copy. Leave empty to copy only the name.": "Parts of the original task to copy. Leave empty to copy only the name.", + "Gid of the job returned by Duplicate Task or Duplicate Project.": "Gid of the job returned by Duplicate Task or Duplicate Project.", + "\"me\", an email address or a user gid.": "\"me\", an email address or a user gid.", + "Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.": "Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.", + "Return incomplete tasks plus tasks completed after this ISO 8601 date-time. Use \"now\" to return only incomplete tasks. Leave empty to return all tasks.": "Return incomplete tasks plus tasks completed after this ISO 8601 date-time. Use \"now\" to return only incomplete tasks. Leave empty to return all tasks.", + "Only return tasks changed after this ISO 8601 date-time, for example 2026-09-01T00:00:00Z.": "Only return tasks changed after this ISO 8601 date-time, for example 2026-09-01T00:00:00Z.", + "Maximum number of tasks to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of tasks to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the project. Obtain it from List Projects or Search Workspace Objects (object type project).": "Gid of the project. Obtain it from List Projects or Search Workspace Objects (object type project).", + "Gid of the parent task. Obtain it from List Project Tasks or Search Workspace Objects.": "Gid of the parent task. Obtain it from List Project Tasks or Search Workspace Objects.", + "Maximum number of subtasks to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of subtasks to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the task that will contain the subtask. Obtain it from List Project Tasks or Search Workspace Objects.": "Gid of the task that will contain the subtask. Obtain it from List Project Tasks or Search Workspace Objects.", + "Short title of the subtask, for example \"Collect screenshots\".": "Short title of the subtask, for example \"Collect screenshots\".", + "Plain-text description. Cannot be combined with HTML Description.": "Plain-text description. Cannot be combined with HTML Description.", + "Rich-text description wrapped in .... Cannot be combined with Description.": "Rich-text description wrapped in .... Cannot be combined with Description.", + "All-day due date in YYYY-MM-DD format. Cannot be combined with Due Date and Time.": "All-day due date in YYYY-MM-DD format. Cannot be combined with Due Date and Time.", + "Start date in YYYY-MM-DD format. Requires a due date or due time.": "Start date in YYYY-MM-DD format. Requires a due date or due time.", + "Gid of the task to move. Obtain it from List Project Tasks or Search Workspace Objects.": "Gid of the task to move. Obtain it from List Project Tasks or Search Workspace Objects.", + "Gid of the new parent task, or \"none\" to remove the current parent.": "Gid of the new parent task, or \"none\" to remove the current parent.", + "Gid of an existing subtask of the new parent to place this task before. Cannot be combined with Insert After Subtask.": "Gid of an existing subtask of the new parent to place this task before. Cannot be combined with Insert After Subtask.", + "Gid of an existing subtask of the new parent to place this task after. Cannot be combined with Insert Before Subtask.": "Gid of an existing subtask of the new parent to place this task after. Cannot be combined with Insert Before Subtask.", + "Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.": "Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.", + "Maximum number of projects to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of projects to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the project to add the task to. Obtain it from List Projects.": "Gid of the project to add the task to. Obtain it from List Projects.", + "Gid of a section in that project to put the task in (at the bottom). Obtain it from List Sections.": "Gid of a section in that project to put the task in (at the bottom). Obtain it from List Sections.", + "Gid of a task in the project to place this task before. Cannot be combined with Insert After Task.": "Gid of a task in the project to place this task before. Cannot be combined with Insert After Task.", + "Gid of a task in the project to place this task after. Cannot be combined with Insert Before Task.": "Gid of a task in the project to place this task after. Cannot be combined with Insert Before Task.", + "Gid of the project to remove the task from. Obtain it from List Task Projects.": "Gid of the project to remove the task from. Obtain it from List Task Projects.", + "Users to add, one per item: \"me\", an email address or a user gid.": "Users to add, one per item: \"me\", an email address or a user gid.", + "Users to remove, one per item: \"me\", an email address or a user gid.": "Users to remove, one per item: \"me\", an email address or a user gid.", + "Gid of the task to comment on. Obtain it from List Project Tasks or Search Workspace Objects.": "Gid of the task to comment on. Obtain it from List Project Tasks or Search Workspace Objects.", + "Plain-text comment. Provide this or HTML Text, not both.": "Plain-text comment. Provide this or HTML Text, not both.", + "Rich-text comment in Asana rich text, wrapped in ..., for example Done, see the doc. Provide this or Text, not both.": "Rich-text comment in Asana rich text, wrapped in ..., for example Done, see the doc. Provide this or Text, not both.", + "Yes pins the comment to the top of the task. Leave empty to post it unpinned.": "Yes pins the comment to the top of the task. Leave empty to post it unpinned.", + "Maximum number of stories to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of stories to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the story. Obtain it from List Task Stories or Add Task Comment.": "Gid of the story. Obtain it from List Task Stories or Add Task Comment.", + "Gid of the comment story. Obtain it from List Task Stories or Add Task Comment.": "Gid of the comment story. Obtain it from List Task Stories or Add Task Comment.", + "New plain-text comment; replaces the current text. Cannot be combined with HTML Text.": "New plain-text comment; replaces the current text. Cannot be combined with HTML Text.", + "New rich-text comment wrapped in ...; replaces the current text. Cannot be combined with Text.": "New rich-text comment wrapped in ...; replaces the current text. Cannot be combined with Text.", + "Yes pins the comment, No unpins it. Leave empty to keep the current state.": "Yes pins the comment, No unpins it. Leave empty to keep the current state.", + "Gid of the workspace or organization, for example 1201234567890123. Obtain it from List Workspaces.": "Gid of the workspace or organization, for example 1201234567890123. Obtain it from List Workspaces.", + "Gid of the team to share the project with. Needed when the workspace is an organization; obtain it from List Teams. Asana is phasing this field out in favour of Create Membership with the team as member.": "Gid of the team to share the project with. Needed when the workspace is an organization; obtain it from List Teams. Asana is phasing this field out in favour of Create Membership with the team as member.", + "Name of the project, for example \"Q4 Launch\".": "Name of the project, for example \"Q4 Launch\".", + "Plain-text description of the project.": "Plain-text description of the project.", + "Project color. Leave empty for the default.": "Project color. Leave empty for the default.", + "View the project opens in. Leave empty for list.": "View the project opens in. Leave empty for list.", + "Who can see the project. Leave empty for the workspace default. Admins may restrict the allowed values.": "Who can see the project. Leave empty for the workspace default. Admins may restrict the allowed values.", + "Project start date in YYYY-MM-DD format. Requires a due date.": "Project start date in YYYY-MM-DD format. Requires a due date.", + "Project due date in YYYY-MM-DD format.": "Project due date in YYYY-MM-DD format.", + "Yes returns only archived projects, No only active ones. Leave empty to return both.": "Yes returns only archived projects, No only active ones. Leave empty to return both.", + "Gid of the project to update. Obtain it from List Projects or Search Workspace Objects.": "Gid of the project to update. Obtain it from List Projects or Search Workspace Objects.", + "New project name. Leave empty to keep it.": "New project name. Leave empty to keep it.", + "New plain-text description; replaces the current one. Leave empty to keep it.": "New plain-text description; replaces the current one. Leave empty to keep it.", + "New project color. Leave empty to keep it.": "New project color. Leave empty to keep it.", + "New default view. Leave empty to keep it.": "New default view. Leave empty to keep it.", + "New privacy setting. Making a project private hides it from non-members. Leave empty to keep it.": "New privacy setting. Making a project private hides it from non-members. Leave empty to keep it.", + "New owner: \"me\", an email address or a user gid. Leave empty to keep it.": "New owner: \"me\", an email address or a user gid. Leave empty to keep it.", + "New start date in YYYY-MM-DD format. The project must have a due date.": "New start date in YYYY-MM-DD format. The project must have a due date.", + "New due date in YYYY-MM-DD format.": "New due date in YYYY-MM-DD format.", + "Yes archives the project, No unarchives it. Leave empty to keep the current state.": "Yes archives the project, No unarchives it. Leave empty to keep the current state.", + "Gid of the project to delete. Obtain it from List Projects or Search Workspace Objects.": "Gid of the project to delete. Obtain it from List Projects or Search Workspace Objects.", + "Gid of the project to copy. Obtain it from List Projects or Search Workspace Objects.": "Gid of the project to copy. Obtain it from List Projects or Search Workspace Objects.", + "Name of the new project, for example \"Q1 Launch\".": "Name of the new project, for example \"Q1 Launch\".", + "Optional parts to copy in addition to tasks, views and rules.": "Optional parts to copy in addition to tasks, views and rules.", + "Gid of the project. Obtain it from List Projects or Search Workspace Objects.": "Gid of the project. Obtain it from List Projects or Search Workspace Objects.", + "Name of the section, for example \"In review\". Cannot be empty.": "Name of the section, for example \"In review\". Cannot be empty.", + "Gid of an existing section in this project to place the new one before. Cannot be combined with Insert After Section.": "Gid of an existing section in this project to place the new one before. Cannot be combined with Insert After Section.", + "Gid of an existing section in this project to place the new one after. Cannot be combined with Insert Before Section.": "Gid of an existing section in this project to place the new one after. Cannot be combined with Insert Before Section.", + "Gid of the section. Obtain it from List Sections.": "Gid of the section. Obtain it from List Sections.", + "Maximum number of sections to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of sections to return in this page, from 1 to 100. Defaults to 50.", + "New name of the section. Cannot be empty.": "New name of the section. Cannot be empty.", + "Gid of the section to delete. Obtain it from List Sections.": "Gid of the section to delete. Obtain it from List Sections.", + "Gid of the project both sections belong to. Obtain it from List Projects.": "Gid of the project both sections belong to. Obtain it from List Projects.", + "Gid of the section to move. Obtain it from List Sections.": "Gid of the section to move. Obtain it from List Sections.", + "Gid of the section to place it immediately before. Provide this or After Section.": "Gid of the section to place it immediately before. Provide this or After Section.", + "Gid of the section to place it immediately after. Provide this or Before Section.": "Gid of the section to place it immediately after. Provide this or Before Section.", + "Gid of the target section. Obtain it from List Sections.": "Gid of the target section. Obtain it from List Sections.", + "Gid of a task in the section to place this task before. Cannot be combined with Insert After Task.": "Gid of a task in the section to place this task before. Cannot be combined with Insert After Task.", + "Gid of a task in the section to place this task after. Cannot be combined with Insert Before Task.": "Gid of a task in the section to place this task after. Cannot be combined with Insert Before Task.", + "Gid of the user or team to add. Obtain a team gid from List Teams, or a user gid from Get Current User or Search Workspace Objects.": "Gid of the user or team to add. Obtain a team gid from List Teams, or a user gid from Get Current User or Search Workspace Objects.", + "Access to grant; valid values depend on the parent type. Leave empty for the parent's default.": "Access to grant; valid values depend on the parent type. Leave empty for the parent's default.", + "Gid of the membership. Obtain it from List Memberships or Create Membership.": "Gid of the membership. Obtain it from List Memberships or Create Membership.", + "Gid of the project, goal or portfolio whose members to list. Leave empty only when Member is a team.": "Gid of the project, goal or portfolio whose members to list. Leave empty only when Member is a team.", + "Gid of a user or team to filter on. Required (and must be a team) when Parent is empty.": "Gid of a user or team to filter on. Required (and must be a team) when Parent is empty.", + "Maximum number of memberships to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of memberships to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the membership to delete. Obtain it from List Memberships.": "Gid of the membership to delete. Obtain it from List Memberships.", + "Gid of the project (or portfolio or goal) to post on. Obtain a project gid from List Projects.": "Gid of the project (or portfolio or goal) to post on. Obtain a project gid from List Projects.", + "Overall status; valid values depend on the parent type.": "Overall status; valid values depend on the parent type.", + "Headline of the update, for example \"Week 40: on track for launch\".": "Headline of the update, for example \"Week 40: on track for launch\".", + "Plain-text body of the update. Required by Asana even when HTML Text is also set.": "Plain-text body of the update. Required by Asana even when HTML Text is also set.", + "Optional rich-text body in Asana rich text, wrapped in ....": "Optional rich-text body in Asana rich text, wrapped in ....", + "Gid of the status update. Obtain it from List Status Updates or Create Status Update.": "Gid of the status update. Obtain it from List Status Updates or Create Status Update.", + "Gid of the project, portfolio or goal. Obtain a project gid from List Projects.": "Gid of the project, portfolio or goal. Obtain a project gid from List Projects.", + "Only return updates created after this ISO 8601 date-time, for example 2026-09-01T00:00:00Z.": "Only return updates created after this ISO 8601 date-time, for example 2026-09-01T00:00:00Z.", + "Maximum number of status updates to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of status updates to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the status update to delete. Obtain it from List Status Updates.": "Gid of the status update to delete. Obtain it from List Status Updates.", + "Title of the brief, for example \"Launch plan\".": "Title of the brief, for example \"Launch plan\".", + "Body in Asana rich text, wrapped in .... Cannot be combined with Text.": "Body in Asana rich text, wrapped in .... Cannot be combined with Text.", + "Gid of the project brief. Obtain it from the project_brief field of Get Project, or from Create Project Brief.": "Gid of the project brief. Obtain it from the project_brief field of Get Project, or from Create Project Brief.", + "Gid of the project brief. Obtain it from the project_brief field of Get Project.": "Gid of the project brief. Obtain it from the project_brief field of Get Project.", + "New title. Leave empty to keep it.": "New title. Leave empty to keep it.", + "New body in Asana rich text, wrapped in .... Cannot be combined with Text.": "New body in Asana rich text, wrapped in .... Cannot be combined with Text.", + "New plain-text body. Cannot be combined with HTML Text.": "New plain-text body. Cannot be combined with HTML Text.", + "Gid of the project brief to delete. Obtain it from the project_brief field of Get Project.": "Gid of the project brief to delete. Obtain it from the project_brief field of Get Project.", + "Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces.": "Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces.", + "Name of the tag, for example \"urgent\".": "Name of the tag, for example \"urgent\".", + "Tag color. Leave empty for no color.": "Tag color. Leave empty for no color.", + "Optional description of what the tag means.": "Optional description of what the tag means.", + "Gid of the tag. Obtain it from List Tags, List Task Tags or Search Workspace Objects (object type tag).": "Gid of the tag. Obtain it from List Tags, List Task Tags or Search Workspace Objects (object type tag).", + "Maximum number of tags to return in this page, from 1 to 100. Defaults to 50.": "Maximum number of tags to return in this page, from 1 to 100. Defaults to 50.", + "Gid of the tag. Obtain it from List Tags or Search Workspace Objects.": "Gid of the tag. Obtain it from List Tags or Search Workspace Objects.", + "New name. Leave empty to keep it.": "New name. Leave empty to keep it.", + "New color. Leave empty to keep it.": "New color. Leave empty to keep it.", + "New description; replaces the current one. Leave empty to keep it.": "New description; replaces the current one. Leave empty to keep it.", + "Gid of the tag to delete. Obtain it from List Tags.": "Gid of the tag to delete. Obtain it from List Tags.", + "Gid of the tag to apply. Obtain it from List Tags or Search Workspace Objects (object type tag).": "Gid of the tag to apply. Obtain it from List Tags or Search Workspace Objects (object type tag).", + "Gid of the tag to remove. Obtain it from List Task Tags.": "Gid of the tag to remove. Obtain it from List Task Tags.", + "Gid of the tag. Obtain it from List Tags or Search Workspace Objects (object type tag).": "Gid of the tag. Obtain it from List Tags or Search Workspace Objects (object type tag).", + "Task": "Task", + "User": "User", + "Tag": "Tag", + "Team": "Team", + "Portfolio": "Portfolio", + "Goal": "Goal", + "Custom field": "Custom field", + "Project template": "Project template", + "Yes": "Yes", + "No": "No", + "Attachments": "Attachments", + "Dates": "Dates", + "Dependencies": "Dependencies", + "Description (notes)": "Description (notes)", + "Parent task": "Parent task", + "Projects": "Projects", + "Subtasks": "Subtasks", + "dark-pink": "dark-pink", + "dark-green": "dark-green", + "dark-blue": "dark-blue", + "dark-red": "dark-red", + "dark-teal": "dark-teal", + "dark-brown": "dark-brown", + "dark-orange": "dark-orange", + "dark-purple": "dark-purple", + "dark-warm-gray": "dark-warm-gray", + "light-pink": "light-pink", + "light-green": "light-green", + "light-blue": "light-blue", + "light-red": "light-red", + "light-teal": "light-teal", + "light-brown": "light-brown", + "light-orange": "light-orange", + "light-purple": "light-purple", + "light-warm-gray": "light-warm-gray", + "List": "List", + "Board": "Board", + "Calendar": "Calendar", + "Timeline": "Timeline", + "Public to workspace": "Public to workspace", + "Private (members only)": "Private (members only)", + "Project description (notes)": "Project description (notes)", + "Forms": "Forms", + "Permissions": "Permissions", + "Allocations": "Allocations", + "Task assignees": "Task assignees", + "Task attachments": "Task attachments", + "Task dates": "Task dates", + "Task dependencies": "Task dependencies", + "Task followers": "Task followers", + "Task descriptions": "Task descriptions", + "Task other projects": "Task other projects", + "Task subtasks": "Task subtasks", + "Task tags": "Task tags", + "Task templates": "Task templates", + "Default task type": "Default task type", + "Admin": "Admin", + "Editor": "Editor", + "Commenter": "Commenter", + "Viewer": "Viewer", + "On track": "On track", + "At risk": "At risk", + "Off track": "Off track", + "On hold (projects and portfolios)": "On hold (projects and portfolios)", + "Complete (projects and portfolios)": "Complete (projects and portfolios)", + "Achieved (goals)": "Achieved (goals)", + "Partial (goals)": "Partial (goals)", + "Missed (goals)": "Missed (goals)", + "Dropped": "Dropped", + "Exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.": "Exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.", + "Tags to apply, one per item: a tag gid or the exact tag name in this workspace. Names are matched case-insensitively; an all-digit value is used as a gid when such a tag exists and is otherwise matched as a name; an unknown name fails the call. Create new tags first with Create Tag.": "Tags to apply, one per item: a tag gid or the exact tag name in this workspace. Names are matched case-insensitively; an all-digit value is used as a gid when such a tag exists and is otherwise matched as a name; an unknown name fails the call. Create new tags first with Create Tag.", + "New exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.": "New exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.", + "Share a project, goal, portfolio or custom field with a user or team.": "Share a project, goal, portfolio or custom field with a user or team.", + "User (custom fields only)": "User (custom fields only)", + "Gid of the project (or goal, portfolio, custom field or custom type) to share. Obtain a project gid from List Projects.": "Gid of the project (or goal, portfolio, custom field or custom type) to share. Obtain a project gid from List Projects.", + "Plain-text body; it is sent to Asana as escaped rich text inside .... Cannot be combined with HTML Text.": "Plain-text body; it is sent to Asana as escaped rich text inside .... Cannot be combined with HTML Text." } \ No newline at end of file diff --git a/packages/pieces/community/asana/src/index.ts b/packages/pieces/community/asana/src/index.ts index 4f76dd778b1d..b4459a8d25a0 100644 --- a/packages/pieces/community/asana/src/index.ts +++ b/packages/pieces/community/asana/src/index.ts @@ -1,23 +1,152 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { OAuth2PropertyValue, - PieceAuth, createPiece, } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { asanaCreateTaskAction } from './lib/actions/create-task'; +import { asanaGetCurrentUserAction } from './lib/actions/ai/get-current-user'; +import { asanaListWorkspacesAction } from './lib/actions/ai/list-workspaces'; +import { asanaListTeamsAction } from './lib/actions/ai/list-teams'; +import { asanaSearchWorkspaceObjectsAction } from './lib/actions/ai/search-workspace-objects'; +import { asanaCreateProjectTaskAction } from './lib/actions/ai/create-project-task'; +import { asanaGetTaskAction } from './lib/actions/ai/get-task'; +import { asanaUpdateTaskAction } from './lib/actions/ai/update-task'; +import { asanaDeleteTaskAction } from './lib/actions/ai/delete-task'; +import { asanaDuplicateTaskAction } from './lib/actions/ai/duplicate-task'; +import { asanaGetJobAction } from './lib/actions/ai/get-job'; +import { asanaListAssignedTasksAction } from './lib/actions/ai/list-assigned-tasks'; +import { asanaListProjectTasksAction } from './lib/actions/ai/list-project-tasks'; +import { asanaListSubtasksAction } from './lib/actions/ai/list-subtasks'; +import { asanaCreateSubtaskAction } from './lib/actions/ai/create-subtask'; +import { asanaSetTaskParentAction } from './lib/actions/ai/set-task-parent'; +import { asanaListTaskProjectsAction } from './lib/actions/ai/list-task-projects'; +import { asanaAddTaskToProjectAction } from './lib/actions/ai/add-task-to-project'; +import { asanaRemoveTaskFromProjectAction } from './lib/actions/ai/remove-task-from-project'; +import { asanaAddTaskFollowersAction } from './lib/actions/ai/add-task-followers'; +import { asanaRemoveTaskFollowersAction } from './lib/actions/ai/remove-task-followers'; +import { asanaAddTaskCommentAction } from './lib/actions/ai/add-task-comment'; +import { asanaListTaskStoriesAction } from './lib/actions/ai/list-task-stories'; +import { asanaGetStoryAction } from './lib/actions/ai/get-story'; +import { asanaUpdateCommentAction } from './lib/actions/ai/update-comment'; +import { asanaDeleteCommentAction } from './lib/actions/ai/delete-comment'; +import { asanaCreateProjectAction } from './lib/actions/ai/create-project'; +import { asanaGetProjectAction } from './lib/actions/ai/get-project'; +import { asanaListProjectsAction } from './lib/actions/ai/list-projects'; +import { asanaUpdateProjectAction } from './lib/actions/ai/update-project'; +import { asanaDeleteProjectAction } from './lib/actions/ai/delete-project'; +import { asanaDuplicateProjectAction } from './lib/actions/ai/duplicate-project'; +import { asanaGetProjectTaskCountsAction } from './lib/actions/ai/get-project-task-counts'; +import { asanaAddProjectMembersAction } from './lib/actions/ai/add-project-members'; +import { asanaRemoveProjectMembersAction } from './lib/actions/ai/remove-project-members'; +import { asanaAddProjectFollowersAction } from './lib/actions/ai/add-project-followers'; +import { asanaRemoveProjectFollowersAction } from './lib/actions/ai/remove-project-followers'; +import { asanaCreateSectionAction } from './lib/actions/ai/create-section'; +import { asanaGetSectionAction } from './lib/actions/ai/get-section'; +import { asanaListSectionsAction } from './lib/actions/ai/list-sections'; +import { asanaUpdateSectionAction } from './lib/actions/ai/update-section'; +import { asanaDeleteSectionAction } from './lib/actions/ai/delete-section'; +import { asanaMoveSectionAction } from './lib/actions/ai/move-section'; +import { asanaMoveTaskToSectionAction } from './lib/actions/ai/move-task-to-section'; +import { asanaListSectionTasksAction } from './lib/actions/ai/list-section-tasks'; +import { asanaCreateMembershipAction } from './lib/actions/ai/create-membership'; +import { asanaGetMembershipAction } from './lib/actions/ai/get-membership'; +import { asanaListMembershipsAction } from './lib/actions/ai/list-memberships'; +import { asanaDeleteMembershipAction } from './lib/actions/ai/delete-membership'; +import { asanaCreateStatusUpdateAction } from './lib/actions/ai/create-status-update'; +import { asanaGetStatusUpdateAction } from './lib/actions/ai/get-status-update'; +import { asanaListStatusUpdatesAction } from './lib/actions/ai/list-status-updates'; +import { asanaDeleteStatusUpdateAction } from './lib/actions/ai/delete-status-update'; +import { asanaCreateProjectBriefAction } from './lib/actions/ai/create-project-brief'; +import { asanaGetProjectBriefAction } from './lib/actions/ai/get-project-brief'; +import { asanaUpdateProjectBriefAction } from './lib/actions/ai/update-project-brief'; +import { asanaDeleteProjectBriefAction } from './lib/actions/ai/delete-project-brief'; +import { asanaCreateTagAction } from './lib/actions/ai/create-tag'; +import { asanaGetTagAction } from './lib/actions/ai/get-tag'; +import { asanaListTagsAction } from './lib/actions/ai/list-tags'; +import { asanaUpdateTagAction } from './lib/actions/ai/update-tag'; +import { asanaDeleteTagAction } from './lib/actions/ai/delete-tag'; +import { asanaListTaskTagsAction } from './lib/actions/ai/list-task-tags'; +import { asanaAddTagToTaskAction } from './lib/actions/ai/add-tag-to-task'; +import { asanaRemoveTagFromTaskAction } from './lib/actions/ai/remove-tag-from-task'; +import { asanaListTagTasksAction } from './lib/actions/ai/list-tag-tasks'; import { asanaAuth } from './lib/auth'; export const asana = createPiece({ displayName: 'Asana', description: "Work management platform designed to help teams organize, track, and manage their work.", - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/asana.png', categories: [PieceCategory.PRODUCTIVITY], authors: ["ShayPunter","kishanprmr","MoShizzle","khaledmashaly","abuaboud"], auth: asanaAuth, actions: [ asanaCreateTaskAction, + asanaGetCurrentUserAction, + asanaListWorkspacesAction, + asanaListTeamsAction, + asanaSearchWorkspaceObjectsAction, + asanaCreateProjectTaskAction, + asanaGetTaskAction, + asanaUpdateTaskAction, + asanaDeleteTaskAction, + asanaDuplicateTaskAction, + asanaGetJobAction, + asanaListAssignedTasksAction, + asanaListProjectTasksAction, + asanaListSubtasksAction, + asanaCreateSubtaskAction, + asanaSetTaskParentAction, + asanaListTaskProjectsAction, + asanaAddTaskToProjectAction, + asanaRemoveTaskFromProjectAction, + asanaAddTaskFollowersAction, + asanaRemoveTaskFollowersAction, + asanaAddTaskCommentAction, + asanaListTaskStoriesAction, + asanaGetStoryAction, + asanaUpdateCommentAction, + asanaDeleteCommentAction, + asanaCreateProjectAction, + asanaGetProjectAction, + asanaListProjectsAction, + asanaUpdateProjectAction, + asanaDeleteProjectAction, + asanaDuplicateProjectAction, + asanaGetProjectTaskCountsAction, + asanaAddProjectMembersAction, + asanaRemoveProjectMembersAction, + asanaAddProjectFollowersAction, + asanaRemoveProjectFollowersAction, + asanaCreateSectionAction, + asanaGetSectionAction, + asanaListSectionsAction, + asanaUpdateSectionAction, + asanaDeleteSectionAction, + asanaMoveSectionAction, + asanaMoveTaskToSectionAction, + asanaListSectionTasksAction, + asanaCreateMembershipAction, + asanaGetMembershipAction, + asanaListMembershipsAction, + asanaDeleteMembershipAction, + asanaCreateStatusUpdateAction, + asanaGetStatusUpdateAction, + asanaListStatusUpdatesAction, + asanaDeleteStatusUpdateAction, + asanaCreateProjectBriefAction, + asanaGetProjectBriefAction, + asanaUpdateProjectBriefAction, + asanaDeleteProjectBriefAction, + asanaCreateTagAction, + asanaGetTagAction, + asanaListTagsAction, + asanaUpdateTagAction, + asanaDeleteTagAction, + asanaListTaskTagsAction, + asanaAddTagToTaskAction, + asanaRemoveTagFromTaskAction, + asanaListTagTasksAction, createCustomApiCallAction({ baseUrl: () => `https://app.asana.com/api/1.0`, auth: asanaAuth, diff --git a/packages/pieces/community/asana/src/lib/actions/ai/add-project-followers.ts b/packages/pieces/community/asana/src/lib/actions/ai/add-project-followers.ts new file mode 100644 index 000000000000..b7bce6b1e840 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/add-project-followers.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +export const asanaAddProjectFollowersAction = createAction({ + auth: asanaAuth, + name: 'add_project_followers', + classification: 'WRITE', + displayName: 'Add Project Followers', + description: 'Subscribe users to "task added" notifications of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Adds users as followers of a project so they are notified when tasks are added, and returns the updated project. Users who are not yet members also become members. Existing followers are unaffected, so repeating the call converges and is safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + followers: Property.Array({ + displayName: 'Followers', + description: 'Users to add, one per item: "me", an email address or a user gid.', + required: true, + }), + }, + async run(context) { + const { project, followers } = context.propsValue; + const followerList = asanaUtils.toStringArray(followers); + if (followerList.length === 0) { + throw new Error('Followers must contain at least one user ("me", an email address or a user gid).'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/addFollowers`, + operation: 'Add Project Followers', + query: { opt_fields: ASANA_FIELDS.project }, + data: { followers: followerList.join(',') }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/add-project-members.ts b/packages/pieces/community/asana/src/lib/actions/ai/add-project-members.ts new file mode 100644 index 000000000000..2a2279697982 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/add-project-members.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +export const asanaAddProjectMembersAction = createAction({ + auth: asanaAuth, + name: 'add_project_members', + classification: 'WRITE', + displayName: 'Add Project Members', + description: 'Give users access to an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Adds users as members of a project with the project\'s default access level and returns the updated project; their notification settings may also make them followers. Use Create Membership to add a team or to choose the access level. Existing members are unaffected, so repeating the call converges and is safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + members: Property.Array({ + displayName: 'Members', + description: 'Users to add, one per item: "me", an email address or a user gid.', + required: true, + }), + }, + async run(context) { + const { project, members } = context.propsValue; + const memberList = asanaUtils.toStringArray(members); + if (memberList.length === 0) { + throw new Error('Members must contain at least one user ("me", an email address or a user gid).'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/addMembers`, + operation: 'Add Project Members', + query: { opt_fields: ASANA_FIELDS.project }, + data: { members: memberList.join(',') }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/add-tag-to-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/add-tag-to-task.ts new file mode 100644 index 000000000000..3d51b7983ca8 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/add-tag-to-task.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskTagChangeOutputSchema } from '../../output-schemas'; + +export const asanaAddTagToTaskAction = createAction({ + auth: asanaAuth, + name: 'add_tag_to_task', + classification: 'WRITE', + displayName: 'Add Tag to Task', + description: 'Apply a tag to an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Applies an existing tag to a task, by tag gid (resolve a name with List Tags or Search Workspace Objects, or make a new tag with Create Tag). Applying a tag the task already has converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskTagChangeOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + tag: Property.ShortText({ + displayName: 'Tag GID', + description: 'Gid of the tag to apply. Obtain it from List Tags or Search Workspace Objects (object type tag).', + required: true, + }), + }, + async run(context) { + const { task, tag } = context.propsValue; + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/addTag`, + operation: 'Add Tag to Task', + data: { tag: tag.trim() }, + }); + return { success: true, task_gid: task.trim(), tag_gid: tag.trim() }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/add-task-comment.ts b/packages/pieces/community/asana/src/lib/actions/ai/add-task-comment.ts new file mode 100644 index 000000000000..22bdbc9c4c65 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/add-task-comment.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaStoryOutputSchema } from '../../output-schemas'; + +export const asanaAddTaskCommentAction = createAction({ + auth: asanaAuth, + name: 'add_task_comment', + classification: 'WRITE', + displayName: 'Add Task Comment', + description: 'Post a comment on an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Posts a comment on a task as the connected user, in plain text or Asana rich text (one of the two), optionally pinned. Followers of the task are notified. Each call posts a new comment, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaStoryOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task to comment on. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + text: Property.LongText({ + displayName: 'Text', + description: 'Plain-text comment. Provide this or HTML Text, not both.', + required: false, + }), + html_text: Property.LongText({ + displayName: 'HTML Text', + description: 'Rich-text comment in Asana rich text, wrapped in ..., for example Done, see the doc. Provide this or Text, not both.', + required: false, + }), + is_pinned: asanaProps.optionalBoolean({ + displayName: 'Pin Comment', + description: 'Yes pins the comment to the top of the task. Leave empty to post it unpinned.', + }), + }, + async run(context) { + const { task, text, html_text, is_pinned } = context.propsValue; + asanaUtils.assertNotBoth({ first: text, second: html_text, firstLabel: 'Text', secondLabel: 'HTML Text' }); + if (!asanaUtils.hasValue(text) && !asanaUtils.hasValue(html_text)) { + throw new Error('Provide the comment in Text or HTML Text.'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/stories`, + operation: 'Add Task Comment', + query: { opt_fields: ASANA_FIELDS.story }, + data: { + ...(asanaUtils.hasValue(text) ? { text } : {}), + ...(asanaUtils.hasValue(html_text) ? { html_text } : {}), + ...(is_pinned !== undefined && is_pinned !== null ? { is_pinned } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/add-task-followers.ts b/packages/pieces/community/asana/src/lib/actions/ai/add-task-followers.ts new file mode 100644 index 000000000000..97f79883d860 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/add-task-followers.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskOutputSchema } from '../../output-schemas'; + +export const asanaAddTaskFollowersAction = createAction({ + auth: asanaAuth, + name: 'add_task_followers', + classification: 'WRITE', + displayName: 'Add Task Followers', + description: 'Add followers (collaborators) to an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Adds users as followers of a task so they get its notifications, and returns the updated task. Users already following are unaffected, so repeating the call converges and is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + followers: Property.Array({ + displayName: 'Followers', + description: 'Users to add, one per item: "me", an email address or a user gid.', + required: true, + }), + }, + async run(context) { + const { task, followers } = context.propsValue; + const followerList = asanaUtils.toStringArray(followers); + if (followerList.length === 0) { + throw new Error('Followers must contain at least one user ("me", an email address or a user gid).'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/addFollowers`, + operation: 'Add Task Followers', + query: { opt_fields: ASANA_FIELDS.task }, + data: { followers: followerList }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/add-task-to-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/add-task-to-project.ts new file mode 100644 index 000000000000..7f0b00ab9fdf --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/add-task-to-project.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskProjectChangeOutputSchema } from '../../output-schemas'; + +export const asanaAddTaskToProjectAction = createAction({ + auth: asanaAuth, + name: 'add_task_to_project', + classification: 'WRITE', + displayName: 'Add Task to Project', + description: 'Add an existing task to another Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Adds an existing task to a project (a task can live in several projects), optionally into a section or next to another task; also reorders a task already in the project. Use Move Task to Section to move it between sections of one project. Adding it again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskProjectChangeOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project to add the task to. Obtain it from List Projects.', + required: true, + }), + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of a section in that project to put the task in (at the bottom). Obtain it from List Sections.', + required: false, + }), + insert_before: Property.ShortText({ + displayName: 'Insert Before Task', + description: 'Gid of a task in the project to place this task before. Cannot be combined with Insert After Task.', + required: false, + }), + insert_after: Property.ShortText({ + displayName: 'Insert After Task', + description: 'Gid of a task in the project to place this task after. Cannot be combined with Insert Before Task.', + required: false, + }), + }, + async run(context) { + const { task, project, section, insert_before, insert_after } = context.propsValue; + asanaUtils.assertNotBoth({ + first: insert_before, + second: insert_after, + firstLabel: 'Insert Before Task', + secondLabel: 'Insert After Task', + }); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/addProject`, + operation: 'Add Task to Project', + data: { + project: project.trim(), + ...(asanaUtils.hasValue(section) ? { section: String(section).trim() } : {}), + ...(asanaUtils.hasValue(insert_before) ? { insert_before: String(insert_before).trim() } : {}), + ...(asanaUtils.hasValue(insert_after) ? { insert_after: String(insert_after).trim() } : {}), + }, + }); + return { success: true, task_gid: task.trim(), project_gid: project.trim() }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-membership.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-membership.ts new file mode 100644 index 000000000000..95ec75569125 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-membership.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaMembershipOutputSchema } from '../../output-schemas'; + +const ACCESS_LEVEL_OPTIONS = [ + { label: 'Admin', value: 'admin' }, + { label: 'Editor', value: 'editor' }, + { label: 'Commenter', value: 'commenter' }, + { label: 'Viewer', value: 'viewer' }, + { label: 'User (custom fields only)', value: 'user' }, +]; + +export const asanaCreateMembershipAction = createAction({ + auth: asanaAuth, + name: 'create_membership', + classification: 'WRITE', + displayName: 'Create Membership', + description: 'Share a project, goal, portfolio or custom field with a user or team.', + audience: 'ai', + aiMetadata: { + description: + 'Adds a user or a team as a member of a project (also goals, portfolios and custom fields on paid plans) with a chosen access level, and returns the new membership. Projects accept admin, editor or commenter; goals viewer, commenter, editor or admin; portfolios admin, editor or viewer; custom fields admin, editor or user. A user must already belong to the workspace; otherwise Asana answers "Member is required". Use Add Project Members to add several users at the default level. Not idempotent: a repeat may fail or create a second record.', + idempotent: false, + }, + outputSchema: asanaMembershipOutputSchema, + props: { + parent: Property.ShortText({ + displayName: 'Parent GID', + description: 'Gid of the project (or goal, portfolio, custom field or custom type) to share. Obtain a project gid from List Projects.', + required: true, + }), + member: Property.ShortText({ + displayName: 'Member GID', + description: 'Gid of the user or team to add. Obtain a team gid from List Teams, or a user gid from Get Current User or Search Workspace Objects.', + required: true, + }), + access_level: Property.StaticDropdown({ + displayName: 'Access Level', + description: 'Access to grant; valid values depend on the parent type. Leave empty for the parent\'s default.', + required: false, + options: { disabled: false, options: ACCESS_LEVEL_OPTIONS }, + }), + }, + async run(context) { + const { parent, member, access_level } = context.propsValue; + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: '/memberships', + operation: 'Create Membership', + data: { + parent: parent.trim(), + member: member.trim(), + ...(asanaUtils.hasValue(access_level) ? { access_level } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-project-brief.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-project-brief.ts new file mode 100644 index 000000000000..c4958744e728 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-project-brief.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectBriefOutputSchema } from '../../output-schemas'; + +export const asanaCreateProjectBriefAction = createAction({ + auth: asanaAuth, + name: 'create_project_brief', + classification: 'WRITE', + displayName: 'Create Project Brief', + description: 'Create the brief (overview document) of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Creates the project brief, the overview document shown on a project\'s Overview tab, with a title and a body in rich text (preferred) or plain text, not both; plain text is escaped and wrapped in for Asana. A project has at most one brief; use Get Project to find an existing one (project_brief) and Update Project Brief to change it. Available on the free plan. Not idempotent.', + idempotent: false, + }, + outputSchema: asanaProjectBriefOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'Title of the brief, for example "Launch plan".', + required: false, + }), + html_text: Property.LongText({ + displayName: 'HTML Text', + description: 'Body in Asana rich text, wrapped in .... Cannot be combined with Text.', + required: false, + }), + text: Property.LongText({ + displayName: 'Text', + description: 'Plain-text body; it is sent to Asana as escaped rich text inside .... Cannot be combined with HTML Text.', + required: false, + }), + }, + async run(context) { + const { project, title, html_text, text } = context.propsValue; + asanaUtils.assertNotBoth({ first: html_text, second: text, firstLabel: 'HTML Text', secondLabel: 'Text' }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/project_briefs`, + operation: 'Create Project Brief', + query: { opt_fields: ASANA_FIELDS.projectBrief }, + data: { + ...(asanaUtils.hasValue(title) ? { title } : {}), + ...(asanaUtils.hasValue(html_text) ? { html_text } : {}), + ...(typeof text === 'string' && asanaUtils.hasValue(text) ? { html_text: plainTextToRichText(text) } : {}), + }, + }); + }, +}); + +function plainTextToRichText(text: string): string { + const escaped = text.replace(/&/g, '&').replace(//g, '>'); + return `${escaped}`; +} diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-project-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-project-task.ts new file mode 100644 index 000000000000..6e12e2cdeaa6 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-project-task.ts @@ -0,0 +1,108 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskOutputSchema } from '../../output-schemas'; + +export const asanaCreateProjectTaskAction = createAction({ + auth: asanaAuth, + name: 'create_project_task', + classification: 'WRITE', + displayName: 'Create Project Task', + description: 'Create a task in an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new top-level task in a project, with optional description, assignee, due date or due time, start date, followers and tags (by gid or exact name; an unknown tag name fails the call instead of being dropped). Use Create Subtask to add a task under another task. Each call creates a separate task, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaTaskOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace that owns the project, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.', + required: true, + }), + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project to create the task in. Obtain it from List Projects or Search Workspace Objects (object type project).', + required: true, + }), + name: Property.ShortText({ + displayName: 'Task Name', + description: 'Short title of the task, for example "Draft launch email".', + required: true, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'Plain-text description of the task. Cannot be combined with HTML Description.', + required: false, + }), + html_notes: Property.LongText({ + displayName: 'HTML Description', + description: 'Rich-text description in Asana rich text, wrapped in ..., for example See brief. Cannot be combined with Description.', + required: false, + }), + assignee: Property.ShortText({ + displayName: 'Assignee', + description: 'User to assign: "me", an email address or a user gid.', + required: false, + }), + due_on: Property.ShortText({ + displayName: 'Due Date', + description: 'All-day due date in YYYY-MM-DD format, for example 2026-10-15. Cannot be combined with Due Date and Time.', + required: false, + }), + due_at: Property.ShortText({ + displayName: 'Due Date and Time', + description: 'Exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.', + required: false, + }), + start_on: Property.ShortText({ + displayName: 'Start Date', + description: 'Start date in YYYY-MM-DD format. Asana requires a due date or due time when a start date is set.', + required: false, + }), + followers: Property.Array({ + displayName: 'Followers', + description: 'Users to add as followers, one per item: "me", an email address or a user gid.', + required: false, + }), + tags: Property.Array({ + displayName: 'Tags', + description: 'Tags to apply, one per item: a tag gid or the exact tag name in this workspace. Names are matched case-insensitively; an all-digit value is used as a gid when such a tag exists and is otherwise matched as a name; an unknown name fails the call. Create new tags first with Create Tag.', + required: false, + }), + }, + async run(context) { + const { workspace, project, name, notes, html_notes, assignee, due_on, due_at, start_on, followers, tags } = + context.propsValue; + asanaUtils.assertNotBoth({ first: notes, second: html_notes, firstLabel: 'Description', secondLabel: 'HTML Description' }); + asanaUtils.assertNotBoth({ first: due_on, second: due_at, firstLabel: 'Due Date', secondLabel: 'Due Date and Time' }); + const followerList = asanaUtils.toStringArray(followers); + const tagList = asanaUtils.toStringArray(tags); + const data: Record = { + workspace: workspace.trim(), + projects: [project.trim()], + name, + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + ...(asanaUtils.hasValue(html_notes) ? { html_notes } : {}), + ...(asanaUtils.hasValue(assignee) ? { assignee: String(assignee).trim() } : {}), + ...(asanaUtils.hasValue(due_on) ? { due_on: asanaUtils.assertDate({ value: String(due_on), field: 'Due Date' }) } : {}), + ...(asanaUtils.hasValue(due_at) ? { due_at: asanaUtils.assertDateTime({ value: String(due_at), field: 'Due Date and Time' }) } : {}), + ...(asanaUtils.hasValue(start_on) ? { start_on: asanaUtils.assertDate({ value: String(start_on), field: 'Start Date' }) } : {}), + ...(followerList.length > 0 ? { followers: followerList } : {}), + }; + if (tagList.length > 0) { + data['tags'] = await asanaClient.resolveTagGids({ auth: context.auth, workspace: workspace.trim(), tags: tagList }); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: '/tasks', + operation: 'Create Project Task', + query: { opt_fields: ASANA_FIELDS.task }, + data, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-project.ts new file mode 100644 index 000000000000..9cf6eff09d1d --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-project.ts @@ -0,0 +1,103 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_COLOR_OPTIONS, ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +const DEFAULT_VIEW_OPTIONS = [ + { label: 'List', value: 'list' }, + { label: 'Board', value: 'board' }, + { label: 'Calendar', value: 'calendar' }, + { label: 'Timeline', value: 'timeline' }, +]; + +const PRIVACY_OPTIONS = [ + { label: 'Public to workspace', value: 'public_to_workspace' }, + { label: 'Private (members only)', value: 'private' }, +]; + +export const asanaCreateProjectAction = createAction({ + auth: asanaAuth, + name: 'create_project', + classification: 'WRITE', + displayName: 'Create Project', + description: 'Create a new Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new empty project in a workspace, with optional description, color, default view, privacy and dates. In an organization a Team gid (from List Teams) is optional; without one Asana creates a teamless project visible only to its members; to copy an existing project with its tasks use Duplicate Project instead. Each call creates a separate project, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaProjectOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace or organization, for example 1201234567890123. Obtain it from List Workspaces.', + required: true, + }), + team: Property.ShortText({ + displayName: 'Team GID', + description: 'Gid of the team to share the project with. Needed when the workspace is an organization; obtain it from List Teams. Asana is phasing this field out in favour of Create Membership with the team as member.', + required: false, + }), + name: Property.ShortText({ + displayName: 'Project Name', + description: 'Name of the project, for example "Q4 Launch".', + required: true, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'Plain-text description of the project.', + required: false, + }), + color: Property.StaticDropdown({ + displayName: 'Color', + description: 'Project color. Leave empty for the default.', + required: false, + options: { disabled: false, options: ASANA_COLOR_OPTIONS }, + }), + default_view: Property.StaticDropdown({ + displayName: 'Default View', + description: 'View the project opens in. Leave empty for list.', + required: false, + options: { disabled: false, options: DEFAULT_VIEW_OPTIONS }, + }), + privacy_setting: Property.StaticDropdown({ + displayName: 'Privacy', + description: 'Who can see the project. Leave empty for the workspace default. Admins may restrict the allowed values.', + required: false, + options: { disabled: false, options: PRIVACY_OPTIONS }, + }), + start_on: Property.ShortText({ + displayName: 'Start Date', + description: 'Project start date in YYYY-MM-DD format. Requires a due date.', + required: false, + }), + due_on: Property.ShortText({ + displayName: 'Due Date', + description: 'Project due date in YYYY-MM-DD format.', + required: false, + }), + }, + async run(context) { + const { workspace, team, name, notes, color, default_view, privacy_setting, start_on, due_on } = context.propsValue; + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: '/projects', + operation: 'Create Project', + query: { opt_fields: ASANA_FIELDS.project }, + data: { + workspace: workspace.trim(), + name, + ...(asanaUtils.hasValue(team) ? { team: String(team).trim() } : {}), + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + ...(asanaUtils.hasValue(color) ? { color } : {}), + ...(asanaUtils.hasValue(default_view) ? { default_view } : {}), + ...(asanaUtils.hasValue(privacy_setting) ? { privacy_setting } : {}), + ...(asanaUtils.hasValue(start_on) ? { start_on: asanaUtils.assertDate({ value: String(start_on), field: 'Start Date' }) } : {}), + ...(asanaUtils.hasValue(due_on) ? { due_on: asanaUtils.assertDate({ value: String(due_on), field: 'Due Date' }) } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-section.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-section.ts new file mode 100644 index 000000000000..5714a0bf49ab --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-section.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaSectionOutputSchema } from '../../output-schemas'; + +export const asanaCreateSectionAction = createAction({ + auth: asanaAuth, + name: 'create_section', + classification: 'WRITE', + displayName: 'Create Section', + description: 'Create a section (list heading or board column) in an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new section in a project, at the end or before/after an existing section. Sections are list headings or board columns; move tasks into one with Move Task to Section. Each call creates a separate section, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaSectionOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Section Name', + description: 'Name of the section, for example "In review". Cannot be empty.', + required: true, + }), + insert_before: Property.ShortText({ + displayName: 'Insert Before Section', + description: 'Gid of an existing section in this project to place the new one before. Cannot be combined with Insert After Section.', + required: false, + }), + insert_after: Property.ShortText({ + displayName: 'Insert After Section', + description: 'Gid of an existing section in this project to place the new one after. Cannot be combined with Insert Before Section.', + required: false, + }), + }, + async run(context) { + const { project, name, insert_before, insert_after } = context.propsValue; + asanaUtils.assertNotBoth({ + first: insert_before, + second: insert_after, + firstLabel: 'Insert Before Section', + secondLabel: 'Insert After Section', + }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/sections`, + operation: 'Create Section', + query: { opt_fields: ASANA_FIELDS.section }, + data: { + name, + ...(asanaUtils.hasValue(insert_before) ? { insert_before: String(insert_before).trim() } : {}), + ...(asanaUtils.hasValue(insert_after) ? { insert_after: String(insert_after).trim() } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-status-update.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-status-update.ts new file mode 100644 index 000000000000..3ed62cb48be2 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-status-update.ts @@ -0,0 +1,77 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaStatusUpdateOutputSchema } from '../../output-schemas'; + +const STATUS_TYPE_OPTIONS = [ + { label: 'On track', value: 'on_track' }, + { label: 'At risk', value: 'at_risk' }, + { label: 'Off track', value: 'off_track' }, + { label: 'On hold (projects and portfolios)', value: 'on_hold' }, + { label: 'Complete (projects and portfolios)', value: 'complete' }, + { label: 'Achieved (goals)', value: 'achieved' }, + { label: 'Partial (goals)', value: 'partial' }, + { label: 'Missed (goals)', value: 'missed' }, + { label: 'Dropped', value: 'dropped' }, +]; + +export const asanaCreateStatusUpdateAction = createAction({ + auth: asanaAuth, + name: 'create_status_update', + classification: 'WRITE', + displayName: 'Create Status Update', + description: 'Post a status update on an Asana project, portfolio or goal.', + audience: 'ai', + aiMetadata: { + description: + 'Posts a status update (status type, title, text) on a project, and on paid plans a portfolio or goal; it becomes the current status and notifies followers. Projects accept on_track, at_risk, off_track, on_hold, complete or dropped; goals on_track, at_risk, off_track, achieved, partial, missed or dropped. Each call posts a new update, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaStatusUpdateOutputSchema, + props: { + parent: Property.ShortText({ + displayName: 'Parent GID', + description: 'Gid of the project (or portfolio or goal) to post on. Obtain a project gid from List Projects.', + required: true, + }), + status_type: Property.StaticDropdown({ + displayName: 'Status', + description: 'Overall status; valid values depend on the parent type.', + required: true, + options: { disabled: false, options: STATUS_TYPE_OPTIONS }, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'Headline of the update, for example "Week 40: on track for launch".', + required: false, + }), + text: Property.LongText({ + displayName: 'Text', + description: 'Plain-text body of the update. Required by Asana even when HTML Text is also set.', + required: true, + }), + html_text: Property.LongText({ + displayName: 'HTML Text', + description: 'Optional rich-text body in Asana rich text, wrapped in ....', + required: false, + }), + }, + async run(context) { + const { parent, status_type, title, text, html_text } = context.propsValue; + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: '/status_updates', + operation: 'Create Status Update', + query: { opt_fields: ASANA_FIELDS.statusUpdate }, + data: { + parent: parent.trim(), + status_type, + text, + ...(asanaUtils.hasValue(title) ? { title } : {}), + ...(asanaUtils.hasValue(html_text) ? { html_text } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-subtask.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-subtask.ts new file mode 100644 index 000000000000..8e11b5e13a83 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-subtask.ts @@ -0,0 +1,90 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskOutputSchema } from '../../output-schemas'; + +export const asanaCreateSubtaskAction = createAction({ + auth: asanaAuth, + name: 'create_subtask', + classification: 'WRITE', + displayName: 'Create Subtask', + description: 'Create a subtask under an existing Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new subtask under a parent task, with optional description, assignee, due date or due time, start date and followers. Use Create Project Task for a top-level task in a project. Each call creates a separate subtask, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Parent Task GID', + description: 'Gid of the task that will contain the subtask. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Subtask Name', + description: 'Short title of the subtask, for example "Collect screenshots".', + required: true, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'Plain-text description. Cannot be combined with HTML Description.', + required: false, + }), + html_notes: Property.LongText({ + displayName: 'HTML Description', + description: 'Rich-text description wrapped in .... Cannot be combined with Description.', + required: false, + }), + assignee: Property.ShortText({ + displayName: 'Assignee', + description: 'User to assign: "me", an email address or a user gid.', + required: false, + }), + due_on: Property.ShortText({ + displayName: 'Due Date', + description: 'All-day due date in YYYY-MM-DD format. Cannot be combined with Due Date and Time.', + required: false, + }), + due_at: Property.ShortText({ + displayName: 'Due Date and Time', + description: 'Exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.', + required: false, + }), + start_on: Property.ShortText({ + displayName: 'Start Date', + description: 'Start date in YYYY-MM-DD format. Requires a due date or due time.', + required: false, + }), + followers: Property.Array({ + displayName: 'Followers', + description: 'Users to add as followers, one per item: "me", an email address or a user gid.', + required: false, + }), + }, + async run(context) { + const { task, name, notes, html_notes, assignee, due_on, due_at, start_on, followers } = context.propsValue; + asanaUtils.assertNotBoth({ first: notes, second: html_notes, firstLabel: 'Description', secondLabel: 'HTML Description' }); + asanaUtils.assertNotBoth({ first: due_on, second: due_at, firstLabel: 'Due Date', secondLabel: 'Due Date and Time' }); + const followerList = asanaUtils.toStringArray(followers); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/subtasks`, + operation: 'Create Subtask', + query: { opt_fields: ASANA_FIELDS.task }, + data: { + name, + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + ...(asanaUtils.hasValue(html_notes) ? { html_notes } : {}), + ...(asanaUtils.hasValue(assignee) ? { assignee: String(assignee).trim() } : {}), + ...(asanaUtils.hasValue(due_on) ? { due_on: asanaUtils.assertDate({ value: String(due_on), field: 'Due Date' }) } : {}), + ...(asanaUtils.hasValue(due_at) ? { due_at: asanaUtils.assertDateTime({ value: String(due_at), field: 'Due Date and Time' }) } : {}), + ...(asanaUtils.hasValue(start_on) ? { start_on: asanaUtils.assertDate({ value: String(start_on), field: 'Start Date' }) } : {}), + ...(followerList.length > 0 ? { followers: followerList } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/create-tag.ts b/packages/pieces/community/asana/src/lib/actions/ai/create-tag.ts new file mode 100644 index 000000000000..0a0fa0b3316b --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/create-tag.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_COLOR_OPTIONS, ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTagOutputSchema } from '../../output-schemas'; + +export const asanaCreateTagAction = createAction({ + auth: asanaAuth, + name: 'create_tag', + classification: 'WRITE', + displayName: 'Create Tag', + description: 'Create a tag in an Asana workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new tag in a workspace, with optional color and description. Check List Tags or Search Workspace Objects first to reuse an existing tag instead of making a look-alike; apply it with Add Tag to Task. Each call creates a separate tag, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaTagOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Tag Name', + description: 'Name of the tag, for example "urgent".', + required: true, + }), + color: Property.StaticDropdown({ + displayName: 'Color', + description: 'Tag color. Leave empty for no color.', + required: false, + options: { disabled: false, options: ASANA_COLOR_OPTIONS }, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'Optional description of what the tag means.', + required: false, + }), + }, + async run(context) { + const { workspace, name, color, notes } = context.propsValue; + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: '/tags', + operation: 'Create Tag', + query: { opt_fields: ASANA_FIELDS.tag }, + data: { + workspace: workspace.trim(), + name, + ...(asanaUtils.hasValue(color) ? { color } : {}), + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-comment.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-comment.ts new file mode 100644 index 000000000000..654aa8ec1217 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-comment.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteCommentOutputSchema } from '../../output-schemas'; + +export const asanaDeleteCommentAction = createAction({ + auth: asanaAuth, + name: 'delete_comment', + classification: 'DESTRUCTIVE', + displayName: 'Delete Comment', + description: 'Delete a comment from an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a comment story. Asana only lets users delete stories they created. Irreversible; repeating the call on the same comment fails.', + idempotent: false, + }, + outputSchema: asanaDeleteCommentOutputSchema, + props: { + story: Property.ShortText({ + displayName: 'Comment (Story) GID', + description: 'Gid of the comment story. Obtain it from List Task Stories or Add Task Comment.', + required: true, + }), + }, + async run(context) { + const story = context.propsValue.story.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/stories/${asanaUtils.pathSegment(story)}`, + operation: 'Delete Comment', + }); + return { success: true, story_gid: story }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-membership.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-membership.ts new file mode 100644 index 000000000000..3e1b2f725031 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-membership.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteMembershipOutputSchema } from '../../output-schemas'; + +export const asanaDeleteMembershipAction = createAction({ + auth: asanaAuth, + name: 'delete_membership', + classification: 'DESTRUCTIVE', + displayName: 'Delete Membership', + description: 'Revoke a user\'s or team\'s access to a project, goal or portfolio.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes one membership, revoking that user\'s or team\'s access to the project, goal or portfolio. Access can only come back through a new membership. Not idempotent: repeating the call on the same membership fails.', + idempotent: false, + }, + outputSchema: asanaDeleteMembershipOutputSchema, + props: { + membership: Property.ShortText({ + displayName: 'Membership GID', + description: 'Gid of the membership to delete. Obtain it from List Memberships.', + required: true, + }), + }, + async run(context) { + const membership = context.propsValue.membership.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/memberships/${asanaUtils.pathSegment(membership)}`, + operation: 'Delete Membership', + }); + return { success: true, membership_gid: membership }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-project-brief.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-project-brief.ts new file mode 100644 index 000000000000..6e9408620653 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-project-brief.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteProjectBriefOutputSchema } from '../../output-schemas'; + +export const asanaDeleteProjectBriefAction = createAction({ + auth: asanaAuth, + name: 'delete_project_brief', + classification: 'DESTRUCTIVE', + displayName: 'Delete Project Brief', + description: 'Delete the brief of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a project brief (the project itself is not affected). Irreversible; repeating the call on the same brief fails.', + idempotent: false, + }, + outputSchema: asanaDeleteProjectBriefOutputSchema, + props: { + project_brief: Property.ShortText({ + displayName: 'Project Brief GID', + description: 'Gid of the project brief to delete. Obtain it from the project_brief field of Get Project.', + required: true, + }), + }, + async run(context) { + const projectBrief = context.propsValue.project_brief.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/project_briefs/${asanaUtils.pathSegment(projectBrief)}`, + operation: 'Delete Project Brief', + }); + return { success: true, project_brief_gid: projectBrief }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-project.ts new file mode 100644 index 000000000000..312c3a937437 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-project.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteProjectOutputSchema } from '../../output-schemas'; + +export const asanaDeleteProjectAction = createAction({ + auth: asanaAuth, + name: 'delete_project', + classification: 'DESTRUCTIVE', + displayName: 'Delete Project', + description: 'Delete an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes a project; the API offers no restore, so treat it as permanent. Tasks that live only in this project may go with it, so move any you need first with Add Task to Project. To keep the project but hide it, use Update Project with Archived set to Yes instead. Not idempotent: repeating the call on the same project fails.', + idempotent: false, + }, + outputSchema: asanaDeleteProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project to delete. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + }, + async run(context) { + const project = context.propsValue.project.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/projects/${asanaUtils.pathSegment(project)}`, + operation: 'Delete Project', + }); + return { success: true, project_gid: project }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-section.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-section.ts new file mode 100644 index 000000000000..3e5dffdbffc3 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-section.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteSectionOutputSchema } from '../../output-schemas'; + +export const asanaDeleteSectionAction = createAction({ + auth: asanaAuth, + name: 'delete_section', + classification: 'DESTRUCTIVE', + displayName: 'Delete Section', + description: 'Delete an empty section from an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a section. Asana only deletes empty sections and never the last remaining section of a project, so move its tasks out first with Move Task to Section. Not idempotent: repeating the call on the same section fails.', + idempotent: false, + }, + outputSchema: asanaDeleteSectionOutputSchema, + props: { + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of the section to delete. Obtain it from List Sections.', + required: true, + }), + }, + async run(context) { + const section = context.propsValue.section.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/sections/${asanaUtils.pathSegment(section)}`, + operation: 'Delete Section', + }); + return { success: true, section_gid: section }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-status-update.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-status-update.ts new file mode 100644 index 000000000000..e31a005ad54b --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-status-update.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteStatusUpdateOutputSchema } from '../../output-schemas'; + +export const asanaDeleteStatusUpdateAction = createAction({ + auth: asanaAuth, + name: 'delete_status_update', + classification: 'DESTRUCTIVE', + displayName: 'Delete Status Update', + description: 'Delete a status update from an Asana project, portfolio or goal.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one status update. Irreversible; repeating the call on the same update fails.', + idempotent: false, + }, + outputSchema: asanaDeleteStatusUpdateOutputSchema, + props: { + status_update: Property.ShortText({ + displayName: 'Status Update GID', + description: 'Gid of the status update to delete. Obtain it from List Status Updates.', + required: true, + }), + }, + async run(context) { + const statusUpdate = context.propsValue.status_update.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/status_updates/${asanaUtils.pathSegment(statusUpdate)}`, + operation: 'Delete Status Update', + }); + return { success: true, status_update_gid: statusUpdate }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-tag.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-tag.ts new file mode 100644 index 000000000000..b22786877664 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-tag.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteTagOutputSchema } from '../../output-schemas'; + +export const asanaDeleteTagAction = createAction({ + auth: asanaAuth, + name: 'delete_tag', + classification: 'DESTRUCTIVE', + displayName: 'Delete Tag', + description: 'Delete an Asana tag.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a tag and removes it from every task that carries it (the tasks themselves stay). To take the tag off one task only, use Remove Tag from Task. Irreversible; repeating the call on the same tag fails.', + idempotent: false, + }, + outputSchema: asanaDeleteTagOutputSchema, + props: { + tag: Property.ShortText({ + displayName: 'Tag GID', + description: 'Gid of the tag to delete. Obtain it from List Tags.', + required: true, + }), + }, + async run(context) { + const tag = context.propsValue.tag.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/tags/${asanaUtils.pathSegment(tag)}`, + operation: 'Delete Tag', + }); + return { success: true, tag_gid: tag }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/delete-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/delete-task.ts new file mode 100644 index 000000000000..03206929c68e --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/delete-task.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaDeleteTaskOutputSchema } from '../../output-schemas'; + +export const asanaDeleteTaskAction = createAction({ + auth: asanaAuth, + name: 'delete_task', + classification: 'DESTRUCTIVE', + displayName: 'Delete Task', + description: 'Delete an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes a task. It moves to the trash of the connected user and can be restored from the Asana app for 30 days, after which it is removed permanently. To finish a task instead, use Update Task with Completed set to Yes. Not idempotent: repeating the call on the same task may fail.', + idempotent: false, + }, + outputSchema: asanaDeleteTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task to delete. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.', + required: true, + }), + }, + async run(context) { + const task = context.propsValue.task.trim(); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/tasks/${asanaUtils.pathSegment(task)}`, + operation: 'Delete Task', + }); + return { success: true, task_gid: task }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/duplicate-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/duplicate-project.ts new file mode 100644 index 000000000000..b6ec19f0a01e --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/duplicate-project.ts @@ -0,0 +1,72 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaJobOutputSchema } from '../../output-schemas'; + +const INCLUDE_OPTIONS = [ + { label: 'Members', value: 'members' }, + { label: 'Project description (notes)', value: 'notes' }, + { label: 'Forms', value: 'forms' }, + { label: 'Permissions', value: 'permissions' }, + { label: 'Allocations', value: 'allocations' }, + { label: 'Task assignees', value: 'task_assignee' }, + { label: 'Task attachments', value: 'task_attachments' }, + { label: 'Task dates', value: 'task_dates' }, + { label: 'Task dependencies', value: 'task_dependencies' }, + { label: 'Task followers', value: 'task_followers' }, + { label: 'Task descriptions', value: 'task_notes' }, + { label: 'Task other projects', value: 'task_projects' }, + { label: 'Task subtasks', value: 'task_subtasks' }, + { label: 'Task tags', value: 'task_tags' }, + { label: 'Task templates', value: 'task_templates' }, + { label: 'Default task type', value: 'task_type_default' }, +]; + +export const asanaDuplicateProjectAction = createAction({ + auth: asanaAuth, + name: 'duplicate_project', + classification: 'WRITE', + displayName: 'Duplicate Project', + description: 'Start copying an Asana project into a new project.', + audience: 'ai', + aiMetadata: { + description: + 'Starts an asynchronous copy of a project (tasks, views and rules are always copied) and returns a job (gid, status, new_project). The copy may not be finished yet: poll Get Job with the returned job gid until status is succeeded. Choose extra parts with Include. Each call starts a new copy, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaJobOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project to copy. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'New Project Name', + description: 'Name of the new project, for example "Q1 Launch".', + required: true, + }), + include: Property.StaticMultiSelectDropdown({ + displayName: 'Include', + description: 'Optional parts to copy in addition to tasks, views and rules.', + required: false, + options: { disabled: false, options: INCLUDE_OPTIONS }, + }), + }, + async run(context) { + const { project, name, include } = context.propsValue; + const includeList = asanaUtils.toStringArray(include); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/duplicate`, + operation: 'Duplicate Project', + query: { opt_fields: ASANA_FIELDS.job }, + data: { + name, + ...(includeList.length > 0 ? { include: includeList.join(',') } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/duplicate-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/duplicate-task.ts new file mode 100644 index 000000000000..082ddeb700cb --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/duplicate-task.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaJobOutputSchema } from '../../output-schemas'; + +const INCLUDE_OPTIONS = [ + { label: 'Assignee', value: 'assignee' }, + { label: 'Attachments', value: 'attachments' }, + { label: 'Dates', value: 'dates' }, + { label: 'Dependencies', value: 'dependencies' }, + { label: 'Followers', value: 'followers' }, + { label: 'Description (notes)', value: 'notes' }, + { label: 'Parent task', value: 'parent' }, + { label: 'Projects', value: 'projects' }, + { label: 'Subtasks', value: 'subtasks' }, + { label: 'Tags', value: 'tags' }, +]; + +export const asanaDuplicateTaskAction = createAction({ + auth: asanaAuth, + name: 'duplicate_task', + classification: 'WRITE', + displayName: 'Duplicate Task', + description: 'Start copying an Asana task into a new task.', + audience: 'ai', + aiMetadata: { + description: + 'Starts an asynchronous copy of a task and returns a job (gid, status, new_task). The copy may not be finished yet: poll Get Job with the returned job gid until status is succeeded. Choose which parts to copy with Include. Each call starts a new copy, so retries duplicate.', + idempotent: false, + }, + outputSchema: asanaJobOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task to copy. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'New Task Name', + description: 'Name of the new task, for example "Copy of Draft launch email".', + required: true, + }), + include: Property.StaticMultiSelectDropdown({ + displayName: 'Include', + description: 'Parts of the original task to copy. Leave empty to copy only the name.', + required: false, + options: { disabled: false, options: INCLUDE_OPTIONS }, + }), + }, + async run(context) { + const { task, name, include } = context.propsValue; + const includeList = asanaUtils.toStringArray(include); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/duplicate`, + operation: 'Duplicate Task', + query: { opt_fields: ASANA_FIELDS.job }, + data: { + name, + ...(includeList.length > 0 ? { include: includeList.join(',') } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-current-user.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-current-user.ts new file mode 100644 index 000000000000..4da6cc1913db --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-current-user.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient } from '../../common/client'; +import { asanaCurrentUserOutputSchema } from '../../output-schemas'; + +export const asanaGetCurrentUserAction = createAction({ + auth: asanaAuth, + name: 'get_current_user', + classification: 'READ', + displayName: 'Get Current User', + description: 'Get the Asana user behind this connection.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the connected Asana user (gid, name, email) and the workspaces they belong to. Call it first to learn your own user gid and the workspace gids other actions need; "me" also works wherever a user is expected. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaCurrentUserOutputSchema, + props: {}, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: '/users/me', + operation: 'Get Current User', + query: { opt_fields: ASANA_FIELDS.user }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-job.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-job.ts new file mode 100644 index 000000000000..46bd2cb9f62a --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-job.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaJobOutputSchema } from '../../output-schemas'; + +export const asanaGetJobAction = createAction({ + auth: asanaAuth, + name: 'get_job', + classification: 'READ', + displayName: 'Get Job', + description: 'Check the status of an asynchronous Asana job.', + audience: 'ai', + aiMetadata: { + description: + 'Reads the current status of an asynchronous job started by Duplicate Task or Duplicate Project: not_started, in_progress, succeeded or failed, plus the new_task or new_project it produced. A single status read, not a wait; call it again later while the job is still running. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaJobOutputSchema, + props: { + job: Property.ShortText({ + displayName: 'Job GID', + description: 'Gid of the job returned by Duplicate Task or Duplicate Project.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/jobs/${asanaUtils.pathSegment(context.propsValue.job)}`, + operation: 'Get Job', + query: { opt_fields: ASANA_FIELDS.job }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-membership.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-membership.ts new file mode 100644 index 000000000000..8a3ed5c97352 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-membership.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaMembershipOutputSchema } from '../../output-schemas'; + +export const asanaGetMembershipAction = createAction({ + auth: asanaAuth, + name: 'get_membership', + classification: 'READ', + displayName: 'Get Membership', + description: 'Get one membership of a project, goal or portfolio.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one membership by gid: the member (user or team), the parent (project, goal, portfolio or custom field) and the access level. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaMembershipOutputSchema, + props: { + membership: Property.ShortText({ + displayName: 'Membership GID', + description: 'Gid of the membership. Obtain it from List Memberships or Create Membership.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/memberships/${asanaUtils.pathSegment(context.propsValue.membership)}`, + operation: 'Get Membership', + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-project-brief.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-project-brief.ts new file mode 100644 index 000000000000..54929efa904a --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-project-brief.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectBriefOutputSchema } from '../../output-schemas'; + +export const asanaGetProjectBriefAction = createAction({ + auth: asanaAuth, + name: 'get_project_brief', + classification: 'READ', + displayName: 'Get Project Brief', + description: 'Get the brief (overview document) of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one project brief by gid with its title, plain text, rich text, project and link. The brief gid is the project_brief field of Get Project. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectBriefOutputSchema, + props: { + project_brief: Property.ShortText({ + displayName: 'Project Brief GID', + description: 'Gid of the project brief. Obtain it from the project_brief field of Get Project, or from Create Project Brief.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/project_briefs/${asanaUtils.pathSegment(context.propsValue.project_brief)}`, + operation: 'Get Project Brief', + query: { opt_fields: ASANA_FIELDS.projectBrief }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-project-task-counts.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-project-task-counts.ts new file mode 100644 index 000000000000..8843fcc19df5 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-project-task-counts.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectTaskCountsOutputSchema } from '../../output-schemas'; + +export const asanaGetProjectTaskCountsAction = createAction({ + auth: asanaAuth, + name: 'get_project_task_counts', + classification: 'READ', + displayName: 'Get Project Task Counts', + description: 'Get the number of total, completed and open tasks and milestones in a project.', + audience: 'ai', + aiMetadata: { + description: + 'Returns task and milestone totals for one project (total, completed, incomplete). Use it for progress summaries instead of paging through List Project Tasks. Asana rate-limits this endpoint more strictly, so avoid calling it in tight loops. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectTaskCountsOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + }, + async run(context) { + const project = context.propsValue.project.trim(); + const counts = await asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/projects/${asanaUtils.pathSegment(project)}/task_counts`, + operation: 'Get Project Task Counts', + query: { opt_fields: ASANA_FIELDS.taskCounts }, + }); + return { project_gid: project, ...counts }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-project.ts new file mode 100644 index 000000000000..fe64fa37adb4 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-project.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +export const asanaGetProjectAction = createAction({ + auth: asanaAuth, + name: 'get_project', + classification: 'READ', + displayName: 'Get Project', + description: 'Get the details of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one project by gid with its name, description, archived and completed state, dates, owner, team, privacy, latest status update, members, followers and link. Use Get Project Task Counts for task totals. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects (object type project).', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/projects/${asanaUtils.pathSegment(context.propsValue.project)}`, + operation: 'Get Project', + query: { opt_fields: ASANA_FIELDS.project }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-section.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-section.ts new file mode 100644 index 000000000000..7373e3b86e67 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-section.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaSectionOutputSchema } from '../../output-schemas'; + +export const asanaGetSectionAction = createAction({ + auth: asanaAuth, + name: 'get_section', + classification: 'READ', + displayName: 'Get Section', + description: 'Get the details of an Asana section.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one section by gid with its name, creation time and project. Use List Section Tasks for its tasks. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaSectionOutputSchema, + props: { + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of the section. Obtain it from List Sections.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/sections/${asanaUtils.pathSegment(context.propsValue.section)}`, + operation: 'Get Section', + query: { opt_fields: ASANA_FIELDS.section }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-status-update.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-status-update.ts new file mode 100644 index 000000000000..0f94c221cf37 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-status-update.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaStatusUpdateOutputSchema } from '../../output-schemas'; + +export const asanaGetStatusUpdateAction = createAction({ + auth: asanaAuth, + name: 'get_status_update', + classification: 'READ', + displayName: 'Get Status Update', + description: 'Get one status update of an Asana project, portfolio or goal.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one status update by gid with its status type, title, text, author, parent and timestamps. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaStatusUpdateOutputSchema, + props: { + status_update: Property.ShortText({ + displayName: 'Status Update GID', + description: 'Gid of the status update. Obtain it from List Status Updates or Create Status Update.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/status_updates/${asanaUtils.pathSegment(context.propsValue.status_update)}`, + operation: 'Get Status Update', + query: { opt_fields: ASANA_FIELDS.statusUpdate }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-story.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-story.ts new file mode 100644 index 000000000000..1684f4490233 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-story.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaStoryOutputSchema } from '../../output-schemas'; + +export const asanaGetStoryAction = createAction({ + auth: asanaAuth, + name: 'get_story', + classification: 'READ', + displayName: 'Get Story', + description: 'Get one comment or activity entry of an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one story (a comment or a system activity entry) by gid, including its text, rich text, author, pin state and whether it can be edited. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaStoryOutputSchema, + props: { + story: Property.ShortText({ + displayName: 'Story GID', + description: 'Gid of the story. Obtain it from List Task Stories or Add Task Comment.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/stories/${asanaUtils.pathSegment(context.propsValue.story)}`, + operation: 'Get Story', + query: { opt_fields: ASANA_FIELDS.story }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-tag.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-tag.ts new file mode 100644 index 000000000000..ca62b2db62e9 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-tag.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTagOutputSchema } from '../../output-schemas'; + +export const asanaGetTagAction = createAction({ + auth: asanaAuth, + name: 'get_tag', + classification: 'READ', + displayName: 'Get Tag', + description: 'Get the details of an Asana tag.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one tag by gid with its name, color, description, followers, workspace and link. Use List Tag Tasks for the tasks carrying it. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTagOutputSchema, + props: { + tag: Property.ShortText({ + displayName: 'Tag GID', + description: 'Gid of the tag. Obtain it from List Tags, List Task Tags or Search Workspace Objects (object type tag).', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/tags/${asanaUtils.pathSegment(context.propsValue.tag)}`, + operation: 'Get Tag', + query: { opt_fields: ASANA_FIELDS.tag }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/get-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/get-task.ts new file mode 100644 index 000000000000..80c0648a2cb5 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/get-task.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaGetTaskOutputSchema } from '../../output-schemas'; + +export const asanaGetTaskAction = createAction({ + auth: asanaAuth, + name: 'get_task', + classification: 'READ', + displayName: 'Get Task', + description: 'Get the details of an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one task by gid with its name, description, completion, dates, assignee, parent, projects and sections, tags, followers and link. Use it to read the current state before Update Task, or to inspect a task found by a list or search action. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaGetTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task, for example 1204567890123456. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.', + required: true, + }), + }, + async run(context) { + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/tasks/${asanaUtils.pathSegment(context.propsValue.task)}`, + operation: 'Get Task', + query: { opt_fields: ASANA_FIELDS.task }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-assigned-tasks.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-assigned-tasks.ts new file mode 100644 index 000000000000..4c6c762e02c3 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-assigned-tasks.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTaskListOutputSchema } from '../../output-schemas'; + +export const asanaListAssignedTasksAction = createAction({ + auth: asanaAuth, + name: 'list_assigned_tasks', + classification: 'SEARCH', + displayName: 'List Assigned Tasks', + description: 'List the tasks assigned to a user in a workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Lists tasks assigned to one user (use "me" for the connected user\'s My Tasks) in a workspace, optionally only incomplete ones or ones changed since a moment. Use List Project Tasks for a project\'s tasks instead. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskListOutputSchema, + props: { + assignee: Property.ShortText({ + displayName: 'Assignee', + description: '"me", an email address or a user gid.', + required: true, + defaultValue: 'me', + }), + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.', + required: true, + }), + completed_since: Property.ShortText({ + displayName: 'Completed Since', + description: 'Return incomplete tasks plus tasks completed after this ISO 8601 date-time. Use "now" to return only incomplete tasks. Leave empty to return all tasks.', + required: false, + }), + modified_since: Property.ShortText({ + displayName: 'Modified Since', + description: 'Only return tasks changed after this ISO 8601 date-time, for example 2026-09-01T00:00:00Z.', + required: false, + }), + limit: asanaProps.limit({ noun: 'tasks' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { assignee, workspace, completed_since, modified_since, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: '/tasks', + operation: 'List Assigned Tasks', + query: { + assignee: assignee.trim(), + workspace: workspace.trim(), + completed_since: asanaUtils.hasValue(completed_since) ? String(completed_since).trim() : undefined, + modified_since: asanaUtils.hasValue(modified_since) ? String(modified_since).trim() : undefined, + opt_fields: ASANA_FIELDS.taskList, + }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-memberships.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-memberships.ts new file mode 100644 index 000000000000..5f7b34ac821a --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-memberships.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaMembershipListOutputSchema } from '../../output-schemas'; + +export const asanaListMembershipsAction = createAction({ + auth: asanaAuth, + name: 'list_memberships', + classification: 'SEARCH', + displayName: 'List Memberships', + description: 'List who has access to a project, goal or portfolio, or which projects a team is in.', + audience: 'ai', + aiMetadata: { + description: + 'Lists memberships with their access levels. Pass a Parent (project, goal or portfolio gid) to see its members, optionally filtered to one Member; or pass only a team as Member to list the projects shared with that team. Use it to find the membership gid for Delete Membership. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaMembershipListOutputSchema, + props: { + parent: Property.ShortText({ + displayName: 'Parent GID', + description: 'Gid of the project, goal or portfolio whose members to list. Leave empty only when Member is a team.', + required: false, + }), + member: Property.ShortText({ + displayName: 'Member GID', + description: 'Gid of a user or team to filter on. Required (and must be a team) when Parent is empty.', + required: false, + }), + limit: asanaProps.limit({ noun: 'memberships' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { parent, member, limit, offset } = context.propsValue; + const hasParent = asanaUtils.hasValue(parent); + if (!hasParent && !asanaUtils.hasValue(member)) { + throw new Error('Set Parent (a project, goal or portfolio gid), or set Member to a team gid to list that team\'s projects.'); + } + return asanaClient.asanaListPage({ + auth: context.auth, + path: '/memberships', + operation: 'List Memberships', + query: { + parent: hasParent ? String(parent).trim() : undefined, + member: asanaUtils.hasValue(member) ? String(member).trim() : undefined, + resource_subtype: hasParent ? undefined : 'project_membership', + }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-project-tasks.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-project-tasks.ts new file mode 100644 index 000000000000..60277c2e335b --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-project-tasks.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTaskListOutputSchema } from '../../output-schemas'; + +export const asanaListProjectTasksAction = createAction({ + auth: asanaAuth, + name: 'list_project_tasks', + classification: 'SEARCH', + displayName: 'List Project Tasks', + description: 'List the tasks in an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the tasks of one project in project order, optionally only incomplete ones. This is the free-plan way to browse tasks; use List Section Tasks for one board column or List Assigned Tasks for one person. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskListOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects (object type project).', + required: true, + }), + completed_since: Property.ShortText({ + displayName: 'Completed Since', + description: 'Return incomplete tasks plus tasks completed after this ISO 8601 date-time. Use "now" to return only incomplete tasks. Leave empty to return all tasks.', + required: false, + }), + limit: asanaProps.limit({ noun: 'tasks' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { project, completed_since, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/projects/${asanaUtils.pathSegment(project)}/tasks`, + operation: 'List Project Tasks', + query: { + completed_since: asanaUtils.hasValue(completed_since) ? String(completed_since).trim() : undefined, + opt_fields: ASANA_FIELDS.taskList, + }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-projects.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-projects.ts new file mode 100644 index 000000000000..ab928e27a347 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-projects.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps } from '../../common/client'; +import { asanaProjectListOutputSchema } from '../../output-schemas'; + +export const asanaListProjectsAction = createAction({ + auth: asanaAuth, + name: 'list_projects', + classification: 'SEARCH', + displayName: 'List Projects', + description: 'List the projects in an Asana workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the projects in a workspace that the connected user can see, optionally only archived or only active ones. Use Search Workspace Objects to find a project by name, or List Memberships with a team to list a team\'s projects. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectListOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace or organization, for example 1201234567890123. Obtain it from List Workspaces.', + required: true, + }), + archived: asanaProps.optionalBoolean({ + displayName: 'Archived', + description: 'Yes returns only archived projects, No only active ones. Leave empty to return both.', + }), + limit: asanaProps.limit({ noun: 'projects' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { workspace, archived, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: '/projects', + operation: 'List Projects', + query: { + workspace: workspace.trim(), + archived: archived === undefined || archived === null ? undefined : archived, + opt_fields: ASANA_FIELDS.projectList, + }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-section-tasks.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-section-tasks.ts new file mode 100644 index 000000000000..7fda4df31d52 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-section-tasks.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTaskListOutputSchema } from '../../output-schemas'; + +export const asanaListSectionTasksAction = createAction({ + auth: asanaAuth, + name: 'list_section_tasks', + classification: 'SEARCH', + displayName: 'List Section Tasks', + description: 'List the tasks in one section of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the tasks in one section (board column or list heading), optionally only incomplete ones. Asana documents this endpoint for board-view projects; if it returns nothing for a list-view project, use List Project Tasks instead. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskListOutputSchema, + props: { + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of the section. Obtain it from List Sections.', + required: true, + }), + completed_since: Property.ShortText({ + displayName: 'Completed Since', + description: 'Return incomplete tasks plus tasks completed after this ISO 8601 date-time. Use "now" to return only incomplete tasks. Leave empty to return all tasks.', + required: false, + }), + limit: asanaProps.limit({ noun: 'tasks' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { section, completed_since, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/sections/${asanaUtils.pathSegment(section)}/tasks`, + operation: 'List Section Tasks', + query: { + completed_since: asanaUtils.hasValue(completed_since) ? String(completed_since).trim() : undefined, + opt_fields: ASANA_FIELDS.taskList, + }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-sections.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-sections.ts new file mode 100644 index 000000000000..2c4df4a13dfd --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-sections.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaSectionListOutputSchema } from '../../output-schemas'; + +export const asanaListSectionsAction = createAction({ + auth: asanaAuth, + name: 'list_sections', + classification: 'SEARCH', + displayName: 'List Sections', + description: 'List the sections of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the sections (list headings or board columns) of a project in display order. Use it to find the section gid for Move Task to Section, List Section Tasks or Move Section. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaSectionListOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + limit: asanaProps.limit({ noun: 'sections' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { project, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/projects/${asanaUtils.pathSegment(project)}/sections`, + operation: 'List Sections', + query: { opt_fields: ASANA_FIELDS.section }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-status-updates.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-status-updates.ts new file mode 100644 index 000000000000..a34e58e40f99 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-status-updates.ts @@ -0,0 +1,48 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaStatusUpdateListOutputSchema } from '../../output-schemas'; + +export const asanaListStatusUpdatesAction = createAction({ + auth: asanaAuth, + name: 'list_status_updates', + classification: 'SEARCH', + displayName: 'List Status Updates', + description: 'List the status updates of an Asana project, portfolio or goal.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the status updates posted on a project (or portfolio or goal), optionally only those created after a moment. Use it to review project health over time. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaStatusUpdateListOutputSchema, + props: { + parent: Property.ShortText({ + displayName: 'Parent GID', + description: 'Gid of the project, portfolio or goal. Obtain a project gid from List Projects.', + required: true, + }), + created_since: Property.ShortText({ + displayName: 'Created Since', + description: 'Only return updates created after this ISO 8601 date-time, for example 2026-09-01T00:00:00Z.', + required: false, + }), + limit: asanaProps.limit({ noun: 'status updates' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { parent, created_since, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: '/status_updates', + operation: 'List Status Updates', + query: { + parent: parent.trim(), + created_since: asanaUtils.hasValue(created_since) ? String(created_since).trim() : undefined, + opt_fields: ASANA_FIELDS.statusUpdate, + }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-subtasks.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-subtasks.ts new file mode 100644 index 000000000000..949153ebbf3e --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-subtasks.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTaskListOutputSchema } from '../../output-schemas'; + +export const asanaListSubtasksAction = createAction({ + auth: asanaAuth, + name: 'list_subtasks', + classification: 'SEARCH', + displayName: 'List Subtasks', + description: 'List the subtasks of an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the direct subtasks of one task (one level deep; call again on a subtask for deeper levels). Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskListOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Parent Task GID', + description: 'Gid of the parent task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + limit: asanaProps.limit({ noun: 'subtasks' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { task, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/tasks/${asanaUtils.pathSegment(task)}/subtasks`, + operation: 'List Subtasks', + query: { opt_fields: ASANA_FIELDS.taskList }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-tag-tasks.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-tag-tasks.ts new file mode 100644 index 000000000000..aa8ef1749ba7 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-tag-tasks.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTaskListOutputSchema } from '../../output-schemas'; + +export const asanaListTagTasksAction = createAction({ + auth: asanaAuth, + name: 'list_tag_tasks', + classification: 'SEARCH', + displayName: 'List Tag Tasks', + description: 'List the tasks that carry an Asana tag.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the tasks carrying one tag, across all projects of the workspace. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskListOutputSchema, + props: { + tag: Property.ShortText({ + displayName: 'Tag GID', + description: 'Gid of the tag. Obtain it from List Tags or Search Workspace Objects (object type tag).', + required: true, + }), + limit: asanaProps.limit({ noun: 'tasks' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { tag, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/tags/${asanaUtils.pathSegment(tag)}/tasks`, + operation: 'List Tag Tasks', + query: { opt_fields: ASANA_FIELDS.taskList }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-tags.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-tags.ts new file mode 100644 index 000000000000..e998bdbd74e5 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-tags.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps } from '../../common/client'; +import { asanaTagListOutputSchema } from '../../output-schemas'; + +export const asanaListTagsAction = createAction({ + auth: asanaAuth, + name: 'list_tags', + classification: 'SEARCH', + displayName: 'List Tags', + description: 'List the tags in an Asana workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the tags of a workspace with their gid, name and color. Use it to find a tag gid for Add Tag to Task or List Tag Tasks; Search Workspace Objects finds one tag by name faster. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTagListOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace, for example 1201234567890123. Obtain it from List Workspaces.', + required: true, + }), + limit: asanaProps.limit({ noun: 'tags' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { workspace, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: '/tags', + operation: 'List Tags', + query: { workspace: workspace.trim(), opt_fields: ASANA_FIELDS.tagList }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-task-projects.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-task-projects.ts new file mode 100644 index 000000000000..106953ec92f2 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-task-projects.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaProjectListOutputSchema } from '../../output-schemas'; + +export const asanaListTaskProjectsAction = createAction({ + auth: asanaAuth, + name: 'list_task_projects', + classification: 'SEARCH', + displayName: 'List Task Projects', + description: 'List the projects an Asana task belongs to.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the projects a task is directly in (a task can be in several). Use it before Remove Task from Project or to find where a task lives. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectListOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + limit: asanaProps.limit({ noun: 'projects' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { task, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/tasks/${asanaUtils.pathSegment(task)}/projects`, + operation: 'List Task Projects', + query: { opt_fields: ASANA_FIELDS.projectList }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-task-stories.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-task-stories.ts new file mode 100644 index 000000000000..6a6f3a95a8c9 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-task-stories.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaStoryListOutputSchema } from '../../output-schemas'; + +export const asanaListTaskStoriesAction = createAction({ + auth: asanaAuth, + name: 'list_task_stories', + classification: 'SEARCH', + displayName: 'List Task Stories', + description: 'List the comments and activity of an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the stories of a task, oldest first: comments (resource_subtype comment_added) plus system activity such as assignments and due-date changes. Use it to read the comment thread or to find a comment gid for Update Comment or Delete Comment. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaStoryListOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + limit: asanaProps.limit({ noun: 'stories' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { task, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/tasks/${asanaUtils.pathSegment(task)}/stories`, + operation: 'List Task Stories', + query: { opt_fields: ASANA_FIELDS.storyList }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-task-tags.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-task-tags.ts new file mode 100644 index 000000000000..83766c7a7edd --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-task-tags.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTagListOutputSchema } from '../../output-schemas'; + +export const asanaListTaskTagsAction = createAction({ + auth: asanaAuth, + name: 'list_task_tags', + classification: 'SEARCH', + displayName: 'List Task Tags', + description: 'List the tags on an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the tags currently applied to one task. Use it before Remove Tag from Task. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTagListOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + limit: asanaProps.limit({ noun: 'tags' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { task, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/tasks/${asanaUtils.pathSegment(task)}/tags`, + operation: 'List Task Tags', + query: { opt_fields: ASANA_FIELDS.tagList }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-teams.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-teams.ts new file mode 100644 index 000000000000..d67d4b01e8fd --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-teams.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTeamListOutputSchema } from '../../output-schemas'; + +export const asanaListTeamsAction = createAction({ + auth: asanaAuth, + name: 'list_teams', + classification: 'SEARCH', + displayName: 'List Teams', + description: 'List the teams in an Asana organization.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the teams in an organization that the connected user can see. Teams exist only in organizations (company-domain workspaces); a plain workspace returns an error. Use it to find the team gid Create Project needs in an organization. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaTeamListOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Organization (Workspace) GID', + description: 'Gid of the organization, for example 1201234567890123. Obtain it from List Workspaces (is_organization must be true).', + required: true, + }), + limit: asanaProps.limit({ noun: 'teams' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { workspace, limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: `/workspaces/${asanaUtils.pathSegment(workspace)}/teams`, + operation: 'List Teams', + query: { opt_fields: ASANA_FIELDS.team }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/list-workspaces.ts b/packages/pieces/community/asana/src/lib/actions/ai/list-workspaces.ts new file mode 100644 index 000000000000..9ed4b3d3ac96 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/list-workspaces.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps } from '../../common/client'; +import { asanaWorkspaceListOutputSchema } from '../../output-schemas'; + +export const asanaListWorkspacesAction = createAction({ + auth: asanaAuth, + name: 'list_workspaces', + classification: 'SEARCH', + displayName: 'List Workspaces', + description: 'List the workspaces and organizations the connected user can access.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the workspaces and organizations visible to the connected user, with is_organization (teams exist only in organizations). Use it to find the workspace gid that projects, tags, typeahead search and assigned-task listing require. Paginated with next_offset; read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaWorkspaceListOutputSchema, + props: { + limit: asanaProps.limit({ noun: 'workspaces' }), + offset: asanaProps.offset(), + }, + async run(context) { + const { limit, offset } = context.propsValue; + return asanaClient.asanaListPage({ + auth: context.auth, + path: '/workspaces', + operation: 'List Workspaces', + query: { opt_fields: ASANA_FIELDS.workspace }, + limit, + offset, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/move-section.ts b/packages/pieces/community/asana/src/lib/actions/ai/move-section.ts new file mode 100644 index 000000000000..a75dc6f04a00 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/move-section.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaMoveSectionOutputSchema } from '../../output-schemas'; + +export const asanaMoveSectionAction = createAction({ + auth: asanaAuth, + name: 'move_section', + classification: 'WRITE', + displayName: 'Move Section', + description: 'Reorder a section within its Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Moves a section immediately before or after another section of the same project (exactly one of the two). Sections cannot move between projects. Repeating the same move converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaMoveSectionOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project both sections belong to. Obtain it from List Projects.', + required: true, + }), + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of the section to move. Obtain it from List Sections.', + required: true, + }), + before_section: Property.ShortText({ + displayName: 'Before Section', + description: 'Gid of the section to place it immediately before. Provide this or After Section.', + required: false, + }), + after_section: Property.ShortText({ + displayName: 'After Section', + description: 'Gid of the section to place it immediately after. Provide this or Before Section.', + required: false, + }), + }, + async run(context) { + const { project, section, before_section, after_section } = context.propsValue; + asanaUtils.assertNotBoth({ + first: before_section, + second: after_section, + firstLabel: 'Before Section', + secondLabel: 'After Section', + }); + if (!asanaUtils.hasValue(before_section) && !asanaUtils.hasValue(after_section)) { + throw new Error('Set Before Section or After Section to say where the section goes.'); + } + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/sections/insert`, + operation: 'Move Section', + data: { + section: section.trim(), + ...(asanaUtils.hasValue(before_section) ? { before_section: String(before_section).trim() } : {}), + ...(asanaUtils.hasValue(after_section) ? { after_section: String(after_section).trim() } : {}), + }, + }); + return { success: true, project_gid: project.trim(), section_gid: section.trim() }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/move-task-to-section.ts b/packages/pieces/community/asana/src/lib/actions/ai/move-task-to-section.ts new file mode 100644 index 000000000000..6e17291cabc2 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/move-task-to-section.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaMoveTaskToSectionOutputSchema } from '../../output-schemas'; + +export const asanaMoveTaskToSectionAction = createAction({ + auth: asanaAuth, + name: 'move_task_to_section', + classification: 'WRITE', + displayName: 'Move Task to Section', + description: 'Move a task into a section of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Puts a task into a section (list heading or board column), removing it from its other sections in that project; it goes to the top unless you place it before or after another task. Use Add Task to Project to put a task into another project. Repeating the same move converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaMoveTaskToSectionOutputSchema, + props: { + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of the target section. Obtain it from List Sections.', + required: true, + }), + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task to move. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + insert_before: Property.ShortText({ + displayName: 'Insert Before Task', + description: 'Gid of a task in the section to place this task before. Cannot be combined with Insert After Task.', + required: false, + }), + insert_after: Property.ShortText({ + displayName: 'Insert After Task', + description: 'Gid of a task in the section to place this task after. Cannot be combined with Insert Before Task.', + required: false, + }), + }, + async run(context) { + const { section, task, insert_before, insert_after } = context.propsValue; + asanaUtils.assertNotBoth({ + first: insert_before, + second: insert_after, + firstLabel: 'Insert Before Task', + secondLabel: 'Insert After Task', + }); + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.POST, + path: `/sections/${asanaUtils.pathSegment(section)}/addTask`, + operation: 'Move Task to Section', + data: { + task: task.trim(), + ...(asanaUtils.hasValue(insert_before) ? { insert_before: String(insert_before).trim() } : {}), + ...(asanaUtils.hasValue(insert_after) ? { insert_after: String(insert_after).trim() } : {}), + }, + }); + return { success: true, section_gid: section.trim(), task_gid: task.trim() }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/remove-project-followers.ts b/packages/pieces/community/asana/src/lib/actions/ai/remove-project-followers.ts new file mode 100644 index 000000000000..19f1be94fc50 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/remove-project-followers.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +export const asanaRemoveProjectFollowersAction = createAction({ + auth: asanaAuth, + name: 'remove_project_followers', + classification: 'WRITE', + displayName: 'Remove Project Followers', + description: 'Unsubscribe users from "task added" notifications of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Removes users from the followers of a project so they stop getting "task added" notifications, and returns the updated project. Their membership is not affected (use Remove Project Members for that). Users not following are ignored, so repeating the call converges and is safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + followers: Property.Array({ + displayName: 'Followers', + description: 'Users to remove, one per item: "me", an email address or a user gid.', + required: true, + }), + }, + async run(context) { + const { project, followers } = context.propsValue; + const followerList = asanaUtils.toStringArray(followers); + if (followerList.length === 0) { + throw new Error('Followers must contain at least one user ("me", an email address or a user gid).'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/removeFollowers`, + operation: 'Remove Project Followers', + query: { opt_fields: ASANA_FIELDS.project }, + data: { followers: followerList.join(',') }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/remove-project-members.ts b/packages/pieces/community/asana/src/lib/actions/ai/remove-project-members.ts new file mode 100644 index 000000000000..6d01d5670721 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/remove-project-members.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaAuth, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +export const asanaRemoveProjectMembersAction = createAction({ + auth: asanaAuth, + name: 'remove_project_members', + classification: 'DESTRUCTIVE', + displayName: 'Remove Project Members', + description: 'Revoke users\' membership of an Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Removes users from the members of a project, revoking the access that membership gave them (a private project becomes invisible to them), and returns the updated project. Use Remove Project Followers to only stop notifications. Users who are not members are ignored, so repeating the call converges.', + idempotent: true, + }, + outputSchema: asanaProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + members: Property.Array({ + displayName: 'Members', + description: 'Users to remove, one per item: "me", an email address or a user gid.', + required: true, + }), + }, + async run(context) { + const { project, members } = context.propsValue; + const memberList = asanaUtils.toStringArray(members); + if (memberList.length === 0) { + throw new Error('Members must contain at least one user ("me", an email address or a user gid).'); + } + const memberGids = await Promise.all( + memberList.map((member) => toUserGid({ auth: context.auth, member })), + ); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/projects/${asanaUtils.pathSegment(project)}/removeMembers`, + operation: 'Remove Project Members', + query: { opt_fields: ASANA_FIELDS.project }, + data: { members: memberGids.join(',') }, + }); + }, +}); + +async function toUserGid({ auth, member }: { auth: AsanaAuth; member: string }): Promise { + if (/^\d+$/.test(member)) { + return member; + } + const user = await asanaClient.asanaData({ + auth, + method: HttpMethod.GET, + path: `/users/${asanaUtils.pathSegment(member)}`, + operation: 'Remove Project Members', + query: { opt_fields: 'gid' }, + }); + return String(user['gid']); +} diff --git a/packages/pieces/community/asana/src/lib/actions/ai/remove-tag-from-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/remove-tag-from-task.ts new file mode 100644 index 000000000000..c38eaa81b1e2 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/remove-tag-from-task.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskTagChangeOutputSchema } from '../../output-schemas'; + +export const asanaRemoveTagFromTaskAction = createAction({ + auth: asanaAuth, + name: 'remove_tag_from_task', + classification: 'WRITE', + displayName: 'Remove Tag from Task', + description: 'Take a tag off an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one tag from one task; the tag itself and its other tasks are unaffected (use Delete Tag to delete the tag everywhere). Removing a tag the task does not have converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskTagChangeOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + tag: Property.ShortText({ + displayName: 'Tag GID', + description: 'Gid of the tag to remove. Obtain it from List Task Tags.', + required: true, + }), + }, + async run(context) { + const { task, tag } = context.propsValue; + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/removeTag`, + operation: 'Remove Tag from Task', + data: { tag: tag.trim() }, + }); + return { success: true, task_gid: task.trim(), tag_gid: tag.trim() }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/remove-task-followers.ts b/packages/pieces/community/asana/src/lib/actions/ai/remove-task-followers.ts new file mode 100644 index 000000000000..166831599912 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/remove-task-followers.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskOutputSchema } from '../../output-schemas'; + +export const asanaRemoveTaskFollowersAction = createAction({ + auth: asanaAuth, + name: 'remove_task_followers', + classification: 'WRITE', + displayName: 'Remove Task Followers', + description: 'Remove followers from an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Removes users from the followers of a task so they stop getting its notifications, and returns the updated task. Users not following are ignored, so repeating the call converges and is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + followers: Property.Array({ + displayName: 'Followers', + description: 'Users to remove, one per item: "me", an email address or a user gid.', + required: true, + }), + }, + async run(context) { + const { task, followers } = context.propsValue; + const followerList = asanaUtils.toStringArray(followers); + if (followerList.length === 0) { + throw new Error('Followers must contain at least one user ("me", an email address or a user gid).'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/removeFollowers`, + operation: 'Remove Task Followers', + query: { opt_fields: ASANA_FIELDS.task }, + data: { followers: followerList }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/remove-task-from-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/remove-task-from-project.ts new file mode 100644 index 000000000000..163af0c42f51 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/remove-task-from-project.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskProjectChangeOutputSchema } from '../../output-schemas'; + +export const asanaRemoveTaskFromProjectAction = createAction({ + auth: asanaAuth, + name: 'remove_task_from_project', + classification: 'WRITE', + displayName: 'Remove Task from Project', + description: 'Remove a task from one of its Asana projects.', + audience: 'ai', + aiMetadata: { + description: + 'Removes a task from one project without deleting it; the task stays in its other projects (or becomes project-less) and can be added back with Add Task to Project, though its section position is lost. Use Delete Task to delete it. Removing it again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskProjectChangeOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project to remove the task from. Obtain it from List Task Projects.', + required: true, + }), + }, + async run(context) { + const { task, project } = context.propsValue; + await asanaClient.asanaEmpty({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/removeProject`, + operation: 'Remove Task from Project', + data: { project: project.trim() }, + }); + return { success: true, task_gid: task.trim(), project_gid: project.trim() }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/search-workspace-objects.ts b/packages/pieces/community/asana/src/lib/actions/ai/search-workspace-objects.ts new file mode 100644 index 000000000000..3301458602da --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/search-workspace-objects.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaSearchWorkspaceObjectsOutputSchema } from '../../output-schemas'; + +const RESOURCE_TYPE_OPTIONS = [ + { label: 'Task', value: 'task' }, + { label: 'Project', value: 'project' }, + { label: 'User', value: 'user' }, + { label: 'Tag', value: 'tag' }, + { label: 'Team', value: 'team' }, + { label: 'Portfolio', value: 'portfolio' }, + { label: 'Goal', value: 'goal' }, + { label: 'Custom field', value: 'custom_field' }, + { label: 'Project template', value: 'project_template' }, +]; + +export const asanaSearchWorkspaceObjectsAction = createAction({ + auth: asanaAuth, + name: 'search_workspace_objects', + classification: 'SEARCH', + displayName: 'Search Workspace Objects', + description: 'Find tasks, projects, users, tags or teams by name in a workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Name-based typeahead lookup in one workspace: returns the gid and name of matching tasks, projects, users, tags, teams and more. Use it to turn a name into the gid other actions need; it works on free plans. Results are fuzzy and capped at one page (up to 100), with no pagination. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: asanaSearchWorkspaceObjectsOutputSchema, + props: { + workspace: Property.ShortText({ + displayName: 'Workspace GID', + description: 'Gid of the workspace to search, for example 1201234567890123. Obtain it from List Workspaces or Get Current User.', + required: true, + }), + resource_type: Property.StaticDropdown({ + displayName: 'Object Type', + description: 'Which kind of object to return.', + required: true, + options: { disabled: false, options: RESOURCE_TYPE_OPTIONS }, + }), + query: Property.ShortText({ + displayName: 'Query', + description: 'Text to match against object names, for example "Q4 launch". Leave empty to get recently relevant objects of this type.', + required: false, + }), + count: Property.Number({ + displayName: 'Count', + description: 'Number of results to return, from 1 to 100. Defaults to 20.', + required: false, + }), + }, + async run(context) { + const { workspace, resource_type, query, count } = context.propsValue; + if (asanaUtils.hasValue(count) && (!Number.isInteger(count) || Number(count) < 1 || Number(count) > 100)) { + throw new Error(`Count must be a whole number between 1 and 100, got ${count}.`); + } + const data = await asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.GET, + path: `/workspaces/${asanaUtils.pathSegment(workspace)}/typeahead`, + operation: 'Search Workspace Objects', + query: { resource_type, query, count }, + }); + return { data }; + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/set-task-parent.ts b/packages/pieces/community/asana/src/lib/actions/ai/set-task-parent.ts new file mode 100644 index 000000000000..8a2e22e70178 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/set-task-parent.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTaskOutputSchema } from '../../output-schemas'; + +const NO_PARENT = 'none'; + +export const asanaSetTaskParentAction = createAction({ + auth: asanaAuth, + name: 'set_task_parent', + classification: 'WRITE', + displayName: 'Set Task Parent', + description: 'Make a task a subtask of another task, or detach it from its parent.', + audience: 'ai', + aiMetadata: { + description: + 'Moves an existing task under a new parent task (making it a subtask), or detaches it to top level when Parent is "none". Optionally positions it before or after a sibling subtask. Setting the same parent again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task to move. Obtain it from List Project Tasks or Search Workspace Objects.', + required: true, + }), + parent: Property.ShortText({ + displayName: 'Parent Task GID', + description: 'Gid of the new parent task, or "none" to remove the current parent.', + required: true, + }), + insert_before: Property.ShortText({ + displayName: 'Insert Before Subtask', + description: 'Gid of an existing subtask of the new parent to place this task before. Cannot be combined with Insert After Subtask.', + required: false, + }), + insert_after: Property.ShortText({ + displayName: 'Insert After Subtask', + description: 'Gid of an existing subtask of the new parent to place this task after. Cannot be combined with Insert Before Subtask.', + required: false, + }), + }, + async run(context) { + const { task, parent, insert_before, insert_after } = context.propsValue; + asanaUtils.assertNotBoth({ + first: insert_before, + second: insert_after, + firstLabel: 'Insert Before Subtask', + secondLabel: 'Insert After Subtask', + }); + const trimmedParent = parent.trim(); + const parentValue = trimmedParent.toLowerCase() === NO_PARENT ? null : trimmedParent; + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.POST, + path: `/tasks/${asanaUtils.pathSegment(task)}/setParent`, + operation: 'Set Task Parent', + query: { opt_fields: ASANA_FIELDS.task }, + data: { + parent: parentValue, + ...(asanaUtils.hasValue(insert_before) ? { insert_before: String(insert_before).trim() } : {}), + ...(asanaUtils.hasValue(insert_after) ? { insert_after: String(insert_after).trim() } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/update-comment.ts b/packages/pieces/community/asana/src/lib/actions/ai/update-comment.ts new file mode 100644 index 000000000000..d6aa10125512 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/update-comment.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaStoryOutputSchema } from '../../output-schemas'; + +export const asanaUpdateCommentAction = createAction({ + auth: asanaAuth, + name: 'update_comment', + classification: 'WRITE', + displayName: 'Update Comment', + description: 'Edit or pin a comment on an Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Replaces the text of a comment (plain text or rich text, one of the two) and/or pins or unpins it; unset fields stay unchanged. Only comment stories can be edited, and usually only by their author. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaStoryOutputSchema, + props: { + story: Property.ShortText({ + displayName: 'Comment (Story) GID', + description: 'Gid of the comment story. Obtain it from List Task Stories or Add Task Comment.', + required: true, + }), + text: Property.LongText({ + displayName: 'Text', + description: 'New plain-text comment; replaces the current text. Cannot be combined with HTML Text.', + required: false, + }), + html_text: Property.LongText({ + displayName: 'HTML Text', + description: 'New rich-text comment wrapped in ...; replaces the current text. Cannot be combined with Text.', + required: false, + }), + is_pinned: asanaProps.optionalBoolean({ + displayName: 'Pinned', + description: 'Yes pins the comment, No unpins it. Leave empty to keep the current state.', + }), + }, + async run(context) { + const { story, text, html_text, is_pinned } = context.propsValue; + asanaUtils.assertNotBoth({ first: text, second: html_text, firstLabel: 'Text', secondLabel: 'HTML Text' }); + const data: Record = { + ...(asanaUtils.hasValue(text) ? { text } : {}), + ...(asanaUtils.hasValue(html_text) ? { html_text } : {}), + ...(is_pinned !== undefined && is_pinned !== null ? { is_pinned } : {}), + }; + asanaUtils.assertNotEmpty({ patch: data, fields: 'Text, HTML Text or Pinned' }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.PUT, + path: `/stories/${asanaUtils.pathSegment(story)}`, + operation: 'Update Comment', + query: { opt_fields: ASANA_FIELDS.story }, + data, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/update-project-brief.ts b/packages/pieces/community/asana/src/lib/actions/ai/update-project-brief.ts new file mode 100644 index 000000000000..704d68cc1b10 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/update-project-brief.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaProjectBriefOutputSchema } from '../../output-schemas'; + +export const asanaUpdateProjectBriefAction = createAction({ + auth: asanaAuth, + name: 'update_project_brief', + classification: 'WRITE', + displayName: 'Update Project Brief', + description: 'Change the title or body of an Asana project brief.', + audience: 'ai', + aiMetadata: { + description: + 'Updates only the brief fields you set: the title, and/or the body in rich text (preferred) or plain text, not both. A new body replaces the whole current body. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectBriefOutputSchema, + props: { + project_brief: Property.ShortText({ + displayName: 'Project Brief GID', + description: 'Gid of the project brief. Obtain it from the project_brief field of Get Project.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'New title. Leave empty to keep it.', + required: false, + }), + html_text: Property.LongText({ + displayName: 'HTML Text', + description: 'New body in Asana rich text, wrapped in .... Cannot be combined with Text.', + required: false, + }), + text: Property.LongText({ + displayName: 'Text', + description: 'New plain-text body. Cannot be combined with HTML Text.', + required: false, + }), + }, + async run(context) { + const { project_brief, title, html_text, text } = context.propsValue; + asanaUtils.assertNotBoth({ first: html_text, second: text, firstLabel: 'HTML Text', secondLabel: 'Text' }); + const data: Record = { + ...(asanaUtils.hasValue(title) ? { title } : {}), + ...(asanaUtils.hasValue(html_text) ? { html_text } : {}), + ...(asanaUtils.hasValue(text) ? { text } : {}), + }; + asanaUtils.assertNotEmpty({ patch: data, fields: 'Title, HTML Text or Text' }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.PUT, + path: `/project_briefs/${asanaUtils.pathSegment(project_brief)}`, + operation: 'Update Project Brief', + query: { opt_fields: ASANA_FIELDS.projectBrief }, + data, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/update-project.ts b/packages/pieces/community/asana/src/lib/actions/ai/update-project.ts new file mode 100644 index 000000000000..4c6c12829bf2 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/update-project.ts @@ -0,0 +1,113 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_COLOR_OPTIONS, ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaProjectOutputSchema } from '../../output-schemas'; + +const DEFAULT_VIEW_OPTIONS = [ + { label: 'List', value: 'list' }, + { label: 'Board', value: 'board' }, + { label: 'Calendar', value: 'calendar' }, + { label: 'Timeline', value: 'timeline' }, +]; + +const PRIVACY_OPTIONS = [ + { label: 'Public to workspace', value: 'public_to_workspace' }, + { label: 'Private (members only)', value: 'private' }, +]; + +export const asanaUpdateProjectAction = createAction({ + auth: asanaAuth, + name: 'update_project', + classification: 'WRITE', + displayName: 'Update Project', + description: 'Change fields of an existing Asana project.', + audience: 'ai', + aiMetadata: { + description: + 'Updates only the project fields you set (name, description, color, default view, privacy, owner, dates, archived); everything else is left unchanged. Archiving hides the project but keeps it, and Archived set to No restores it. Members and followers are changed with Add Project Members and Add Project Followers. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaProjectOutputSchema, + props: { + project: Property.ShortText({ + displayName: 'Project GID', + description: 'Gid of the project to update. Obtain it from List Projects or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Project Name', + description: 'New project name. Leave empty to keep it.', + required: false, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'New plain-text description; replaces the current one. Leave empty to keep it.', + required: false, + }), + color: Property.StaticDropdown({ + displayName: 'Color', + description: 'New project color. Leave empty to keep it.', + required: false, + options: { disabled: false, options: ASANA_COLOR_OPTIONS }, + }), + default_view: Property.StaticDropdown({ + displayName: 'Default View', + description: 'New default view. Leave empty to keep it.', + required: false, + options: { disabled: false, options: DEFAULT_VIEW_OPTIONS }, + }), + privacy_setting: Property.StaticDropdown({ + displayName: 'Privacy', + description: 'New privacy setting. Making a project private hides it from non-members. Leave empty to keep it.', + required: false, + options: { disabled: false, options: PRIVACY_OPTIONS }, + }), + owner: Property.ShortText({ + displayName: 'Owner', + description: 'New owner: "me", an email address or a user gid. Leave empty to keep it.', + required: false, + }), + start_on: Property.ShortText({ + displayName: 'Start Date', + description: 'New start date in YYYY-MM-DD format. The project must have a due date.', + required: false, + }), + due_on: Property.ShortText({ + displayName: 'Due Date', + description: 'New due date in YYYY-MM-DD format.', + required: false, + }), + archived: asanaProps.optionalBoolean({ + displayName: 'Archived', + description: 'Yes archives the project, No unarchives it. Leave empty to keep the current state.', + }), + }, + async run(context) { + const { project, name, notes, color, default_view, privacy_setting, owner, start_on, due_on, archived } = + context.propsValue; + const data: Record = { + ...(asanaUtils.hasValue(name) ? { name } : {}), + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + ...(asanaUtils.hasValue(color) ? { color } : {}), + ...(asanaUtils.hasValue(default_view) ? { default_view } : {}), + ...(asanaUtils.hasValue(privacy_setting) ? { privacy_setting } : {}), + ...(asanaUtils.hasValue(owner) ? { owner: String(owner).trim() } : {}), + ...(asanaUtils.hasValue(start_on) ? { start_on: asanaUtils.assertDate({ value: String(start_on), field: 'Start Date' }) } : {}), + ...(asanaUtils.hasValue(due_on) ? { due_on: asanaUtils.assertDate({ value: String(due_on), field: 'Due Date' }) } : {}), + ...(archived !== undefined && archived !== null ? { archived } : {}), + }; + asanaUtils.assertNotEmpty({ + patch: data, + fields: 'Project Name, Description, Color, Default View, Privacy, Owner, Start Date, Due Date or Archived', + }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.PUT, + path: `/projects/${asanaUtils.pathSegment(project)}`, + operation: 'Update Project', + query: { opt_fields: ASANA_FIELDS.project }, + data, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/update-section.ts b/packages/pieces/community/asana/src/lib/actions/ai/update-section.ts new file mode 100644 index 000000000000..670fe1222c66 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/update-section.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaSectionOutputSchema } from '../../output-schemas'; + +export const asanaUpdateSectionAction = createAction({ + auth: asanaAuth, + name: 'update_section', + classification: 'WRITE', + displayName: 'Rename Section', + description: 'Rename an Asana section.', + audience: 'ai', + aiMetadata: { + description: + 'Renames a section; the name is the only section field Asana lets you change. Use Move Section to reorder it. Setting the same name again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaSectionOutputSchema, + props: { + section: Property.ShortText({ + displayName: 'Section GID', + description: 'Gid of the section. Obtain it from List Sections.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Section Name', + description: 'New name of the section. Cannot be empty.', + required: true, + }), + }, + async run(context) { + const { section, name } = context.propsValue; + if (name.trim() === '') { + throw new Error('Section Name cannot be empty.'); + } + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.PUT, + path: `/sections/${asanaUtils.pathSegment(section)}`, + operation: 'Update Section', + query: { opt_fields: ASANA_FIELDS.section }, + data: { name }, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/update-tag.ts b/packages/pieces/community/asana/src/lib/actions/ai/update-tag.ts new file mode 100644 index 000000000000..bd022467240b --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/update-tag.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_COLOR_OPTIONS, ASANA_FIELDS, AsanaRecord, asanaClient, asanaUtils } from '../../common/client'; +import { asanaTagOutputSchema } from '../../output-schemas'; + +export const asanaUpdateTagAction = createAction({ + auth: asanaAuth, + name: 'update_tag', + classification: 'WRITE', + displayName: 'Update Tag', + description: 'Rename or recolor an Asana tag.', + audience: 'ai', + aiMetadata: { + description: + 'Updates only the tag fields you set (name, color, description); the change shows on every task carrying the tag. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTagOutputSchema, + props: { + tag: Property.ShortText({ + displayName: 'Tag GID', + description: 'Gid of the tag. Obtain it from List Tags or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Tag Name', + description: 'New name. Leave empty to keep it.', + required: false, + }), + color: Property.StaticDropdown({ + displayName: 'Color', + description: 'New color. Leave empty to keep it.', + required: false, + options: { disabled: false, options: ASANA_COLOR_OPTIONS }, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'New description; replaces the current one. Leave empty to keep it.', + required: false, + }), + }, + async run(context) { + const { tag, name, color, notes } = context.propsValue; + const data: Record = { + ...(asanaUtils.hasValue(name) ? { name } : {}), + ...(asanaUtils.hasValue(color) ? { color } : {}), + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + }; + asanaUtils.assertNotEmpty({ patch: data, fields: 'Tag Name, Color or Description' }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.PUT, + path: `/tags/${asanaUtils.pathSegment(tag)}`, + operation: 'Update Tag', + query: { opt_fields: ASANA_FIELDS.tag }, + data, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/ai/update-task.ts b/packages/pieces/community/asana/src/lib/actions/ai/update-task.ts new file mode 100644 index 000000000000..ebff20fad420 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/actions/ai/update-task.ts @@ -0,0 +1,93 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { asanaAuth } from '../../auth'; +import { ASANA_FIELDS, AsanaRecord, asanaClient, asanaProps, asanaUtils } from '../../common/client'; +import { asanaTaskOutputSchema } from '../../output-schemas'; + +export const asanaUpdateTaskAction = createAction({ + auth: asanaAuth, + name: 'update_task', + classification: 'WRITE', + displayName: 'Update Task', + description: 'Change fields of an existing Asana task.', + audience: 'ai', + aiMetadata: { + description: + 'Updates only the task fields you set (name, description, assignee, due date or due time, start date, completed); everything else is left unchanged. Use it to complete or reopen a task too. Projects, tags, followers and parent are changed with their own actions (Add Task to Project, Add Tag to Task, Add Task Followers, Set Task Parent). Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: asanaTaskOutputSchema, + props: { + task: Property.ShortText({ + displayName: 'Task GID', + description: 'Gid of the task to update. Obtain it from List Project Tasks, List Assigned Tasks or Search Workspace Objects.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Task Name', + description: 'New title of the task. Leave empty to keep the current name.', + required: false, + }), + notes: Property.LongText({ + displayName: 'Description', + description: 'New plain-text description; replaces the whole current description. Cannot be combined with HTML Description. Leave empty to keep it.', + required: false, + }), + html_notes: Property.LongText({ + displayName: 'HTML Description', + description: 'New rich-text description wrapped in ...; replaces the whole current description. Cannot be combined with Description.', + required: false, + }), + assignee: Property.ShortText({ + displayName: 'Assignee', + description: 'New assignee: "me", an email address or a user gid. Leave empty to keep the current assignee.', + required: false, + }), + due_on: Property.ShortText({ + displayName: 'Due Date', + description: 'New all-day due date in YYYY-MM-DD format. Cannot be combined with Due Date and Time.', + required: false, + }), + due_at: Property.ShortText({ + displayName: 'Due Date and Time', + description: 'New exact due moment as an ISO 8601 date-time that ends in Z (UTC) or a +hh:mm/-hh:mm offset, for example 2026-10-15T17:00:00Z or 2026-10-15T19:00:00+02:00; a value without an offset is rejected. Cannot be combined with Due Date.', + required: false, + }), + start_on: Property.ShortText({ + displayName: 'Start Date', + description: 'New start date in YYYY-MM-DD format. The task must have (or get) a due date.', + required: false, + }), + completed: asanaProps.optionalBoolean({ + displayName: 'Completed', + description: 'Yes marks the task complete, No reopens it. Leave empty to keep the current state.', + }), + }, + async run(context) { + const { task, name, notes, html_notes, assignee, due_on, due_at, start_on, completed } = context.propsValue; + asanaUtils.assertNotBoth({ first: notes, second: html_notes, firstLabel: 'Description', secondLabel: 'HTML Description' }); + asanaUtils.assertNotBoth({ first: due_on, second: due_at, firstLabel: 'Due Date', secondLabel: 'Due Date and Time' }); + const data: Record = { + ...(asanaUtils.hasValue(name) ? { name } : {}), + ...(asanaUtils.hasValue(notes) ? { notes } : {}), + ...(asanaUtils.hasValue(html_notes) ? { html_notes } : {}), + ...(asanaUtils.hasValue(assignee) ? { assignee: String(assignee).trim() } : {}), + ...(asanaUtils.hasValue(due_on) ? { due_on: asanaUtils.assertDate({ value: String(due_on), field: 'Due Date' }) } : {}), + ...(asanaUtils.hasValue(due_at) ? { due_at: asanaUtils.assertDateTime({ value: String(due_at), field: 'Due Date and Time' }) } : {}), + ...(asanaUtils.hasValue(start_on) ? { start_on: asanaUtils.assertDate({ value: String(start_on), field: 'Start Date' }) } : {}), + ...(completed !== undefined && completed !== null ? { completed } : {}), + }; + asanaUtils.assertNotEmpty({ + patch: data, + fields: 'Task Name, Description, HTML Description, Assignee, Due Date, Due Date and Time, Start Date or Completed', + }); + return asanaClient.asanaData({ + auth: context.auth, + method: HttpMethod.PUT, + path: `/tasks/${asanaUtils.pathSegment(task)}`, + operation: 'Update Task', + query: { opt_fields: ASANA_FIELDS.task }, + data, + }); + }, +}); diff --git a/packages/pieces/community/asana/src/lib/actions/create-task.ts b/packages/pieces/community/asana/src/lib/actions/create-task.ts index 077e44549993..52767223ff5d 100644 --- a/packages/pieces/community/asana/src/lib/actions/create-task.ts +++ b/packages/pieces/community/asana/src/lib/actions/create-task.ts @@ -3,15 +3,17 @@ import { getAccessTokenOrThrow, HttpMethod } from '@activepieces/pieces-common'; import dayjs from 'dayjs'; import { asanaAuth } from '../auth'; import { Property, createAction } from '@activepieces/pieces-framework'; +import { asanaCreateTaskOutputSchema } from '../output-schemas'; export const asanaCreateTaskAction = createAction({ auth: asanaAuth, name: 'create_task', classification: 'WRITE', description: 'Create a new task', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new task in an Asana project, setting its name, description (notes), optional due date, assignee, and tags. Use when an agent needs to add a work item to Asana. Requires a workspace and project; not idempotent — each call creates a separate task even with identical input.', idempotent: false }, displayName: 'Create Task', + outputSchema: asanaCreateTaskOutputSchema, props: { workspace: asanaCommon.workspace, project: asanaCommon.project, diff --git a/packages/pieces/community/asana/src/lib/common/client.ts b/packages/pieces/community/asana/src/lib/common/client.ts new file mode 100644 index 000000000000..f51b388fe2c6 --- /dev/null +++ b/packages/pieces/community/asana/src/lib/common/client.ts @@ -0,0 +1,520 @@ +import { + AuthenticationType, + httpClient, + HttpError, + HttpMethod, +} from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; + +const BASE_URL = 'https://app.asana.com/api/1.0'; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; +const MAX_TAG_PAGES = 20; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-](\d{2}):(\d{2}))$/i; +const MAX_ERROR_BODY_LENGTH = 300; +const GID_PATTERN = /^\d+$/; + +const RATE_LIMIT_GUIDANCE = + 'Asana allows about 150 requests per minute on free workspaces and 1,500 on paid ones, plus concurrency and cost limits. Wait about a minute (Asana sends the exact delay in its Retry-After header) before retrying; rejected requests still count against the quota.'; + +function extractVendorMessage(body: unknown): string | undefined { + if (typeof body === 'string' && body.trim() !== '') { + const trimmed = body.trim(); + return trimmed.length > MAX_ERROR_BODY_LENGTH ? `${trimmed.slice(0, MAX_ERROR_BODY_LENGTH)}...` : trimmed; + } + if (typeof body !== 'object' || body === null || !('errors' in body)) { + return undefined; + } + const errors = body.errors; + if (!Array.isArray(errors)) { + return undefined; + } + const messages: string[] = []; + for (const entry of errors) { + if (typeof entry === 'object' && entry !== null && 'message' in entry && typeof entry.message === 'string') { + messages.push(entry.message); + } + } + return messages.length === 0 ? undefined : messages.join('; '); +} + +function describeFailure({ + status, + body, + operation, +}: { + status: number; + body: unknown; + operation: string; +}): string { + const vendorMessage = extractVendorMessage(body); + const vendorSuffix = vendorMessage === undefined ? '' : ` Asana said: "${vendorMessage}".`; + switch (status) { + case 400: + return `Asana rejected ${operation} as invalid.${vendorSuffix}`; + case 401: + return `Asana rejected the connection while running ${operation}: the access token is invalid or has expired. Reconnect the Asana account in Activepieces.${vendorSuffix}`; + case 402: + return `This Asana feature needs a paid plan: ${operation} is above the current premium level of the workspace (Starter, Advanced or Enterprise is required).${vendorSuffix}`; + case 403: + return `Asana refused ${operation}: the connected user does not have permission for this resource or action.${vendorSuffix}`; + case 404: + return `Asana could not find the resource for ${operation}. Check the gid; the object may not exist, may have been deleted, or may not be visible to the connected user.${vendorSuffix}`; + case 429: + return `Asana rate limit reached while running ${operation}. ${RATE_LIMIT_GUIDANCE}`; + default: + return `Asana returned HTTP ${status} for ${operation}.${vendorSuffix}`; + } +} + +function toQueryParams(query: AsanaQuery | undefined): Record { + const params: Record = {}; + if (query === undefined) { + return params; + } + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') { + continue; + } + params[key] = String(value); + } + return params; +} + +async function asanaApi({ + auth, + method, + path, + query, + data, + operation, +}: AsanaRequestParams): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${BASE_URL}${path}`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth.access_token, + }, + queryParams: toQueryParams(query), + body: data === undefined ? undefined : { data }, + }); + return response.body; + } catch (error) { + if (error instanceof HttpError) { + const status = error.response.status; + throw new AsanaApiError({ + status, + message: describeFailure({ status, body: error.response.body, operation }), + }); + } + throw error; + } +} + +async function asanaData(params: AsanaRequestParams): Promise { + const body = await asanaApi>(params); + return body.data; +} + +async function asanaEmpty(params: AsanaRequestParams): Promise { + await asanaApi(params); +} + +function assertLimit(limit: number | undefined | null): number { + if (limit === undefined || limit === null) { + return DEFAULT_LIMIT; + } + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new Error(`Limit must be a whole number between 1 and ${MAX_LIMIT}, got ${limit}.`); + } + return limit; +} + +async function asanaListPage({ + auth, + path, + query, + limit, + offset, + operation, +}: AsanaListParams): Promise> { + const body = await asanaApi>({ + auth, + method: HttpMethod.GET, + path, + operation, + query: { + ...query, + limit: assertLimit(limit), + offset: hasValue(offset) ? offset : undefined, + }, + }); + const data = Array.isArray(body.data) ? body.data : []; + const nextOffset = body.next_page?.offset; + return { + data, + next_offset: typeof nextOffset === 'string' && nextOffset !== '' ? nextOffset : null, + }; +} + +async function fetchWorkspaceTags({ + auth, + workspace, +}: { + auth: AsanaAuth; + workspace: string; +}): Promise<{ tags: AsanaNamedRecord[]; truncated: boolean }> { + const tags: AsanaNamedRecord[] = []; + let offset: string | null = null; + for (let page = 0; page < MAX_TAG_PAGES; page++) { + const result: AsanaPage = await asanaListPage({ + auth, + path: '/tags', + query: { workspace, opt_fields: 'name' }, + limit: MAX_LIMIT, + offset: offset ?? undefined, + operation: 'Create Project Task (tag lookup)', + }); + tags.push(...result.data); + offset = result.next_offset; + if (offset === null) { + break; + } + } + return { tags, truncated: offset !== null }; +} + +async function isExistingTagGid({ auth, gid }: { auth: AsanaAuth; gid: string }): Promise { + try { + await asanaApi({ + auth, + method: HttpMethod.GET, + path: `/tags/${encodeURIComponent(gid)}`, + query: { opt_fields: 'name' }, + operation: 'Create Project Task (tag gid check)', + }); + return true; + } catch (error) { + if (error instanceof AsanaApiError && (error.status === 404 || error.status === 400)) { + return false; + } + throw error; + } +} + +async function resolveTagGids({ + auth, + workspace, + tags, +}: { + auth: AsanaAuth; + workspace: string; + tags: string[]; +}): Promise { + const pending: string[] = []; + const confirmedGids = new Set(); + for (const tag of tags) { + if (GID_PATTERN.test(tag) && (await isExistingTagGid({ auth, gid: tag }))) { + confirmedGids.add(tag); + continue; + } + pending.push(tag); + } + const byName = new Map(); + let truncated = false; + if (pending.length > 0) { + const lookup = await fetchWorkspaceTags({ auth, workspace }); + truncated = lookup.truncated; + for (const candidate of lookup.tags) { + if (typeof candidate.name === 'string' && !byName.has(candidate.name.toLowerCase())) { + byName.set(candidate.name.toLowerCase(), candidate.gid); + } + } + } + const missing: string[] = []; + const gids: string[] = []; + for (const tag of tags) { + if (confirmedGids.has(tag)) { + gids.push(tag); + continue; + } + const match = byName.get(tag.toLowerCase()); + if (match === undefined) { + missing.push(tag); + continue; + } + gids.push(match); + } + if (missing.length > 0) { + const names = missing.map((name) => `"${name}"`).join(', '); + if (truncated) { + throw new Error( + `Tag ${names} was not found in the first ${(MAX_TAG_PAGES * MAX_LIMIT).toLocaleString('en-US')} tags of workspace ${workspace}; pass the tag gid instead (find it with List Tags or Search Workspace Objects).` + ); + } + throw new Error( + `No tag named ${names} exists in workspace ${workspace}. Create it first with Create Tag, or pass existing tag gids from List Tags.` + ); + } + return gids; +} + +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +function toStringArray(value: unknown): string[] { + if (typeof value === 'string') { + return toStringArray(parseListString(value)); + } + if (!Array.isArray(value)) { + return []; + } + return value + .filter((item) => typeof item === 'string' || typeof item === 'number') + .map((item) => String(item).trim()) + .filter((item) => item !== ''); +} + +function parseListString(value: string): unknown[] { + const trimmed = value.trim(); + if (trimmed.startsWith('[')) { + const parsed = parseJson(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + } + return trimmed.split(','); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function isCalendarDate(value: string): boolean { + const parsed = new Date(`${value}T00:00:00Z`); + return DATE_PATTERN.test(value) && !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +function isInRange({ value, max }: { value: string | undefined; max: number }): boolean { + return value === undefined || Number(value) <= max; +} + +function assertDate({ value, field }: { value: string; field: string }): string { + const trimmed = value.trim(); + if (!isCalendarDate(trimmed)) { + throw new Error(`${field} must be a calendar date in YYYY-MM-DD format, got "${value}".`); + } + return trimmed; +} + +function assertDateTime({ value, field }: { value: string; field: string }): string { + const trimmed = value.trim(); + const parsed = new Date(trimmed); + const match = DATE_TIME_PATTERN.exec(trimmed); + const validParts = + match !== null && + isCalendarDate(trimmed.slice(0, 10)) && + isInRange({ value: match[1], max: 23 }) && + isInRange({ value: match[2], max: 59 }) && + isInRange({ value: match[3], max: 59 }) && + isInRange({ value: match[4], max: 23 }) && + isInRange({ value: match[5], max: 59 }); + if (!validParts || Number.isNaN(parsed.getTime())) { + throw new Error( + `${field} must be an ISO 8601 date-time with a time part that ends in Z or a +hh:mm/-hh:mm offset, such as 2026-10-01T15:00:00Z or 2026-10-01T17:00:00+02:00, got "${value}". Use the date-only field for an all-day date.` + ); + } + return parsed.toISOString(); +} + +function assertNotBoth({ + first, + second, + firstLabel, + secondLabel, +}: { + first: unknown; + second: unknown; + firstLabel: string; + secondLabel: string; +}): void { + if (hasValue(first) && hasValue(second)) { + throw new Error(`Set either ${firstLabel} or ${secondLabel}, not both; Asana accepts only one of them.`); + } +} + +function assertNotEmpty({ patch, fields }: { patch: Record; fields: string }): void { + if (Object.keys(patch).length === 0) { + throw new Error(`Nothing to update: set at least one of ${fields}.`); + } +} + +function pathSegment(value: string): string { + return encodeURIComponent(value.trim()); +} + +function optionalBooleanProp({ + displayName, + description, +}: { + displayName: string; + description: string; +}) { + return Property.StaticDropdown({ + displayName, + description, + required: false, + options: { + disabled: false, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + }); +} + +function limitProp({ noun }: { noun: string }) { + return Property.Number({ + displayName: 'Limit', + description: `Maximum number of ${noun} to return in this page, from 1 to ${MAX_LIMIT}. Defaults to ${DEFAULT_LIMIT}.`, + required: false, + }); +} + +function offsetProp() { + return Property.ShortText({ + displayName: 'Offset', + description: + 'Pagination token for the next page. Pass the `next_offset` value returned by the previous call of this same action; leave empty for the first page. Tokens expire after a while.', + required: false, + }); +} + +export class AsanaApiError extends Error { + public readonly status: number; + + constructor({ status, message }: { status: number; message: string }) { + super(message); + this.name = 'AsanaApiError'; + this.status = status; + } +} + +export const asanaClient = { + asanaApi, + asanaData, + asanaEmpty, + asanaListPage, + resolveTagGids, +}; + +export const asanaProps = { + optionalBoolean: optionalBooleanProp, + limit: limitProp, + offset: offsetProp, +}; + +export const asanaUtils = { + hasValue, + toStringArray, + assertDate, + assertDateTime, + assertNotBoth, + assertNotEmpty, + pathSegment, +}; + +export const ASANA_COLOR_OPTIONS = [ + 'dark-pink', + 'dark-green', + 'dark-blue', + 'dark-red', + 'dark-teal', + 'dark-brown', + 'dark-orange', + 'dark-purple', + 'dark-warm-gray', + 'light-pink', + 'light-green', + 'light-blue', + 'light-red', + 'light-teal', + 'light-brown', + 'light-orange', + 'light-purple', + 'light-warm-gray', +].map((color) => ({ label: color, value: color })); + +export const ASANA_FIELDS = { + task: 'name,resource_subtype,notes,completed,completed_at,created_at,modified_at,due_on,due_at,start_on,start_at,assignee.name,assignee.email,parent.name,projects.name,memberships.project.name,memberships.section.name,tags.name,followers.name,workspace.name,num_subtasks,permalink_url', + taskList: 'name,resource_subtype,completed,completed_at,created_at,modified_at,due_on,due_at,start_on,assignee.name,parent.name,permalink_url', + project: 'name,archived,color,completed,completed_at,created_at,modified_at,default_view,due_on,start_on,notes,owner.name,privacy_setting,team.name,workspace.name,current_status_update.title,current_status_update.status_type,project_brief,members.name,followers.name,permalink_url', + projectList: 'name,archived,color,created_at,modified_at,due_on,start_on,owner.name,privacy_setting,team.name,permalink_url', + story: 'created_at,created_by.name,resource_subtype,type,text,html_text,is_pinned,is_edited,is_editable,sticker_name,target.name', + storyList: 'created_at,created_by.name,resource_subtype,type,text,is_pinned,is_edited', + section: 'name,created_at,project.name', + tag: 'name,color,notes,created_at,followers.name,workspace.name,permalink_url', + tagList: 'name,color,created_at,permalink_url', + job: 'resource_subtype,status,new_task.name,new_project.name,new_project_template.name', + statusUpdate: 'title,text,status_type,resource_subtype,created_at,modified_at,author.name,created_by.name,parent.name', + projectBrief: 'title,text,html_text,permalink_url,project.name', + user: 'name,email,workspaces.name', + workspace: 'name,is_organization,email_domains', + team: 'name,description,visibility,organization.name,permalink_url', + taskCounts: 'num_tasks,num_completed_tasks,num_incomplete_tasks,num_milestones,num_completed_milestones,num_incomplete_milestones', +}; + +export type AsanaAuth = { + access_token: string; +}; + +export type AsanaQuery = Record; + +export type AsanaRequestParams = { + auth: AsanaAuth; + method: HttpMethod; + path: string; + operation: string; + query?: AsanaQuery; + data?: Record; +}; + +export type AsanaListParams = { + auth: AsanaAuth; + path: string; + operation: string; + query?: AsanaQuery; + limit?: number | null; + offset?: string | null; +}; + +export type AsanaEnvelope = { + data: T; +}; + +export type AsanaListEnvelope = { + data: T[]; + next_page?: { offset?: string; path?: string; uri?: string } | null; +}; + +export type AsanaPage = { + data: T[]; + next_offset: string | null; +}; + +export type AsanaNamedRecord = { + gid: string; + name?: string; + resource_type?: string; +}; + +export type AsanaRecord = Record; diff --git a/packages/pieces/community/asana/src/lib/output-schemas.ts b/packages/pieces/community/asana/src/lib/output-schemas.ts new file mode 100644 index 000000000000..d4da252dff2e --- /dev/null +++ b/packages/pieces/community/asana/src/lib/output-schemas.ts @@ -0,0 +1,474 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const namedRefFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'GID' }, + { key: 'name', label: 'Name' }, +]; + +const userRefFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'User GID' }, + { key: 'name', label: 'Name' }, +]; + +const userFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'User GID' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, +]; + +const workspaceRefFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Workspace GID' }, + { key: 'name', label: 'Workspace Name' }, +]; + +const projectRefFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Project GID' }, + { key: 'name', label: 'Project Name' }, +]; + +const taskRefFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Task GID' }, + { key: 'name', label: 'Task Name' }, +]; + +const taskListItemFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Task GID' }, + { key: 'name', label: 'Name' }, + { key: 'resource_subtype', label: 'Task Type' }, + { key: 'completed', label: 'Completed', format: 'boolean' }, + { key: 'completed_at', label: 'Completed At', format: 'datetime' }, + { key: 'due_on', label: 'Due On', format: 'date' }, + { key: 'due_at', label: 'Due At', format: 'datetime' }, + { key: 'start_on', label: 'Start On', format: 'date' }, + { key: 'assignee', label: 'Assignee', children: userRefFields }, + { key: 'parent', label: 'Parent Task', children: taskRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'modified_at', label: 'Modified At', format: 'datetime' }, + { key: 'permalink_url', label: 'Task URL', format: 'url' }, +]; + +const taskFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Task GID' }, + { key: 'name', label: 'Name' }, + { key: 'resource_subtype', label: 'Task Type' }, + { key: 'notes', label: 'Notes' }, + { key: 'completed', label: 'Completed', format: 'boolean' }, + { key: 'completed_at', label: 'Completed At', format: 'datetime' }, + { key: 'due_on', label: 'Due On', format: 'date' }, + { key: 'due_at', label: 'Due At', format: 'datetime' }, + { key: 'start_on', label: 'Start On', format: 'date' }, + { key: 'start_at', label: 'Start At', format: 'datetime' }, + { key: 'assignee', label: 'Assignee', children: userFields }, + { key: 'parent', label: 'Parent Task', children: taskRefFields }, + { key: 'projects', label: 'Projects', labelKey: 'name', listItems: projectRefFields }, + { + key: 'memberships', + label: 'Project Placements', + listItems: [ + { key: 'project', label: 'Project', children: projectRefFields }, + { + key: 'section', + label: 'Section', + children: [ + { key: 'gid', label: 'Section GID' }, + { key: 'name', label: 'Section Name' }, + ], + }, + ], + }, + { key: 'tags', label: 'Tags', labelKey: 'name', listItems: namedRefFields }, + { key: 'followers', label: 'Followers', labelKey: 'name', listItems: userRefFields }, + { key: 'workspace', label: 'Workspace', children: workspaceRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'modified_at', label: 'Modified At', format: 'datetime' }, + { key: 'permalink_url', label: 'Task URL', format: 'url' }, +]; + +const projectListItemFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Project GID' }, + { key: 'name', label: 'Name' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'color', label: 'Color' }, + { key: 'privacy_setting', label: 'Privacy' }, + { key: 'start_on', label: 'Start On', format: 'date' }, + { key: 'due_on', label: 'Due On', format: 'date' }, + { key: 'owner', label: 'Owner', children: userRefFields }, + { key: 'team', label: 'Team', children: namedRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'modified_at', label: 'Modified At', format: 'datetime' }, + { key: 'permalink_url', label: 'Project URL', format: 'url' }, +]; + +const projectFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Project GID' }, + { key: 'name', label: 'Name' }, + { key: 'notes', label: 'Notes' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'completed', label: 'Completed', format: 'boolean' }, + { key: 'completed_at', label: 'Completed At', format: 'datetime' }, + { key: 'color', label: 'Color' }, + { key: 'default_view', label: 'Default View' }, + { key: 'privacy_setting', label: 'Privacy' }, + { key: 'start_on', label: 'Start On', format: 'date' }, + { key: 'due_on', label: 'Due On', format: 'date' }, + { key: 'owner', label: 'Owner', children: userRefFields }, + { key: 'team', label: 'Team', children: namedRefFields }, + { key: 'workspace', label: 'Workspace', children: workspaceRefFields }, + { key: 'members', label: 'Members', labelKey: 'name', listItems: userRefFields }, + { key: 'followers', label: 'Followers', labelKey: 'name', listItems: userRefFields }, + { + key: 'current_status_update', + label: 'Latest Status Update', + children: [ + { key: 'gid', label: 'Status Update GID' }, + { key: 'title', label: 'Title' }, + { key: 'status_type', label: 'Status' }, + ], + }, + { key: 'project_brief', label: 'Project Brief', children: [{ key: 'gid', label: 'Project Brief GID' }] }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'modified_at', label: 'Modified At', format: 'datetime' }, + { key: 'permalink_url', label: 'Project URL', format: 'url' }, +]; + +const sectionFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Section GID' }, + { key: 'name', label: 'Name' }, + { key: 'project', label: 'Project', children: projectRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const storyListItemFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Story GID' }, + { key: 'type', label: 'Story Type' }, + { key: 'resource_subtype', label: 'Event' }, + { key: 'text', label: 'Text' }, + { key: 'is_pinned', label: 'Pinned', format: 'boolean' }, + { key: 'is_edited', label: 'Edited', format: 'boolean' }, + { key: 'created_by', label: 'Created By', children: userRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const storyFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Story GID' }, + { key: 'type', label: 'Story Type' }, + { key: 'resource_subtype', label: 'Event' }, + { key: 'text', label: 'Text' }, + { key: 'html_text', label: 'Rich Text', format: 'html' }, + { key: 'is_pinned', label: 'Pinned', format: 'boolean' }, + { key: 'is_edited', label: 'Edited', format: 'boolean' }, + { key: 'is_editable', label: 'Editable', format: 'boolean' }, + { key: 'sticker_name', label: 'Sticker' }, + { key: 'target', label: 'Task', children: taskRefFields }, + { key: 'created_by', label: 'Created By', children: userRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const tagListItemFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Tag GID' }, + { key: 'name', label: 'Name' }, + { key: 'color', label: 'Color' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'permalink_url', label: 'Tag URL', format: 'url' }, +]; + +const tagFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Tag GID' }, + { key: 'name', label: 'Name' }, + { key: 'color', label: 'Color' }, + { key: 'notes', label: 'Notes' }, + { key: 'followers', label: 'Followers', labelKey: 'name', listItems: userRefFields }, + { key: 'workspace', label: 'Workspace', children: workspaceRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'permalink_url', label: 'Tag URL', format: 'url' }, +]; + +const statusUpdateFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Status Update GID' }, + { key: 'title', label: 'Title' }, + { key: 'status_type', label: 'Status' }, + { key: 'text', label: 'Text' }, + { key: 'resource_subtype', label: 'Update Type' }, + { key: 'parent', label: 'Parent', children: namedRefFields }, + { key: 'author', label: 'Author', children: userRefFields }, + { key: 'created_by', label: 'Created By', children: userRefFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'modified_at', label: 'Modified At', format: 'datetime' }, +]; + +const projectBriefFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Project Brief GID' }, + { key: 'title', label: 'Title' }, + { key: 'text', label: 'Text' }, + { key: 'html_text', label: 'Rich Text', format: 'html' }, + { key: 'project', label: 'Project', children: projectRefFields }, + { key: 'permalink_url', label: 'Brief URL', format: 'url' }, +]; + +const jobFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Job GID' }, + { key: 'resource_subtype', label: 'Job Type' }, + { key: 'status', label: 'Status' }, + { key: 'new_task', label: 'New Task', children: taskRefFields }, + { key: 'new_project', label: 'New Project', children: projectRefFields }, + { + key: 'new_project_template', + label: 'New Project Template', + children: [ + { key: 'gid', label: 'Project Template GID' }, + { key: 'name', label: 'Project Template Name' }, + ], + }, +]; + +const membershipFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Membership GID' }, + { key: 'resource_subtype', label: 'Membership Type' }, + { key: 'access_level', label: 'Access Level' }, + { + key: 'parent', + label: 'Parent', + children: [ + { key: 'gid', label: 'Parent GID' }, + { key: 'resource_type', label: 'Parent Type' }, + { key: 'name', label: 'Parent Name' }, + ], + }, + { + key: 'member', + label: 'Member', + children: [ + { key: 'gid', label: 'Member GID' }, + { key: 'resource_type', label: 'Member Type' }, + { key: 'name', label: 'Member Name' }, + ], + }, +]; + +const currentUserFields: OutputSchema['fields'] = [ + ...userFields, + { key: 'workspaces', label: 'Workspaces', labelKey: 'name', listItems: workspaceRefFields }, +]; + +const workspaceFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Workspace GID' }, + { key: 'name', label: 'Name' }, + { key: 'is_organization', label: 'Is Organization', format: 'boolean' }, + { key: 'email_domains', label: 'Email Domains' }, +]; + +const teamListItemFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'Team GID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'organization', label: 'Organization', children: workspaceRefFields }, + { key: 'permalink_url', label: 'Team URL', format: 'url' }, +]; + +const searchResultFields: OutputSchema['fields'] = [ + { key: 'gid', label: 'GID' }, + { key: 'name', label: 'Name' }, + { key: 'resource_type', label: 'Object Type' }, + { key: 'resource_subtype', label: 'Subtype' }, +]; + +function listPage({ + key, + label, + labelKey, + itemFields, +}: { + key: string; + label: string; + labelKey: string; + itemFields: OutputSchema['fields']; +}): OutputSchema { + return { + fields: [ + { key: 'data', label, labelKey, listItems: itemFields }, + { + key: 'next_offset', + label: 'Next Page Offset', + description: `Pass as offset to fetch the next page of ${key}; empty when there are no more.`, + }, + ], + }; +} + +function confirmation({ + fields, +}: { + fields: { key: string; label: string }[]; +}): OutputSchema { + return { + fields: [{ key: 'success', label: 'Success', format: 'boolean' }, ...fields], + }; +} + +export const asanaTaskOutputSchema: OutputSchema = { fields: taskFields }; + +export const asanaGetTaskOutputSchema: OutputSchema = { + fields: [...taskFields, { key: 'num_subtasks', label: 'Subtask Count', format: 'number' }], +}; + +export const asanaCreateTaskOutputSchema: OutputSchema = { + fields: [ + ...taskFields, + { key: 'assignee_status', label: 'Assignee Status' }, + { key: 'num_likes', label: 'Likes', format: 'number' }, + ], +}; + +export const asanaTaskListOutputSchema = listPage({ + key: 'tasks', + label: 'Tasks', + labelKey: 'name', + itemFields: taskListItemFields, +}); + +export const asanaProjectOutputSchema: OutputSchema = { fields: projectFields }; + +export const asanaProjectListOutputSchema = listPage({ + key: 'projects', + label: 'Projects', + labelKey: 'name', + itemFields: projectListItemFields, +}); + +export const asanaProjectTaskCountsOutputSchema: OutputSchema = { + fields: [ + { key: 'project_gid', label: 'Project GID' }, + { key: 'num_tasks', label: 'Tasks', format: 'number' }, + { key: 'num_completed_tasks', label: 'Completed Tasks', format: 'number' }, + { key: 'num_incomplete_tasks', label: 'Incomplete Tasks', format: 'number' }, + { key: 'num_milestones', label: 'Milestones', format: 'number' }, + { key: 'num_completed_milestones', label: 'Completed Milestones', format: 'number' }, + { key: 'num_incomplete_milestones', label: 'Incomplete Milestones', format: 'number' }, + ], +}; + +export const asanaSectionOutputSchema: OutputSchema = { fields: sectionFields }; + +export const asanaSectionListOutputSchema = listPage({ + key: 'sections', + label: 'Sections', + labelKey: 'name', + itemFields: sectionFields, +}); + +export const asanaStoryOutputSchema: OutputSchema = { fields: storyFields }; + +export const asanaStoryListOutputSchema = listPage({ + key: 'stories', + label: 'Stories', + labelKey: 'text', + itemFields: storyListItemFields, +}); + +export const asanaTagOutputSchema: OutputSchema = { fields: tagFields }; + +export const asanaTagListOutputSchema = listPage({ + key: 'tags', + label: 'Tags', + labelKey: 'name', + itemFields: tagListItemFields, +}); + +export const asanaStatusUpdateOutputSchema: OutputSchema = { fields: statusUpdateFields }; + +export const asanaStatusUpdateListOutputSchema = listPage({ + key: 'status updates', + label: 'Status Updates', + labelKey: 'title', + itemFields: statusUpdateFields, +}); + +export const asanaProjectBriefOutputSchema: OutputSchema = { fields: projectBriefFields }; + +export const asanaJobOutputSchema: OutputSchema = { fields: jobFields }; + +export const asanaMembershipOutputSchema: OutputSchema = { fields: membershipFields }; + +export const asanaMembershipListOutputSchema = listPage({ + key: 'memberships', + label: 'Memberships', + labelKey: 'access_level', + itemFields: membershipFields, +}); + +export const asanaCurrentUserOutputSchema: OutputSchema = { fields: currentUserFields }; + +export const asanaWorkspaceListOutputSchema = listPage({ + key: 'workspaces', + label: 'Workspaces', + labelKey: 'name', + itemFields: workspaceFields, +}); + +export const asanaTeamListOutputSchema = listPage({ + key: 'teams', + label: 'Teams', + labelKey: 'name', + itemFields: teamListItemFields, +}); + +export const asanaSearchWorkspaceObjectsOutputSchema: OutputSchema = { + fields: [{ key: 'data', label: 'Results', labelKey: 'name', listItems: searchResultFields }], +}; + +export const asanaTaskTagChangeOutputSchema = confirmation({ + fields: [ + { key: 'task_gid', label: 'Task GID' }, + { key: 'tag_gid', label: 'Tag GID' }, + ], +}); + +export const asanaTaskProjectChangeOutputSchema = confirmation({ + fields: [ + { key: 'task_gid', label: 'Task GID' }, + { key: 'project_gid', label: 'Project GID' }, + ], +}); + +export const asanaMoveTaskToSectionOutputSchema = confirmation({ + fields: [ + { key: 'task_gid', label: 'Task GID' }, + { key: 'section_gid', label: 'Section GID' }, + ], +}); + +export const asanaMoveSectionOutputSchema = confirmation({ + fields: [ + { key: 'section_gid', label: 'Section GID' }, + { key: 'project_gid', label: 'Project GID' }, + ], +}); + +export const asanaDeleteTaskOutputSchema = confirmation({ fields: [{ key: 'task_gid', label: 'Deleted Task GID' }] }); + +export const asanaDeleteProjectOutputSchema = confirmation({ + fields: [{ key: 'project_gid', label: 'Deleted Project GID' }], +}); + +export const asanaDeleteSectionOutputSchema = confirmation({ + fields: [{ key: 'section_gid', label: 'Deleted Section GID' }], +}); + +export const asanaDeleteTagOutputSchema = confirmation({ fields: [{ key: 'tag_gid', label: 'Deleted Tag GID' }] }); + +export const asanaDeleteCommentOutputSchema = confirmation({ + fields: [{ key: 'story_gid', label: 'Deleted Comment GID' }], +}); + +export const asanaDeleteStatusUpdateOutputSchema = confirmation({ + fields: [{ key: 'status_update_gid', label: 'Deleted Status Update GID' }], +}); + +export const asanaDeleteProjectBriefOutputSchema = confirmation({ + fields: [{ key: 'project_brief_gid', label: 'Deleted Project Brief GID' }], +}); + +export const asanaDeleteMembershipOutputSchema = confirmation({ + fields: [{ key: 'membership_gid', label: 'Deleted Membership GID' }], +}); diff --git a/packages/pieces/community/dropbox/package.json b/packages/pieces/community/dropbox/package.json index 80670277d107..f88fcbb4c63e 100644 --- a/packages/pieces/community/dropbox/package.json +++ b/packages/pieces/community/dropbox/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-dropbox", - "version": "0.8.0", + "version": "0.9.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -15,6 +16,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/dropbox/src/i18n/translation.json b/packages/pieces/community/dropbox/src/i18n/translation.json index 16968be7d81f..48656899fa5d 100644 --- a/packages/pieces/community/dropbox/src/i18n/translation.json +++ b/packages/pieces/community/dropbox/src/i18n/translation.json @@ -13,6 +13,41 @@ "Move folder": "Move folder", "Copy folder": "Copy folder", "List a folder": "List a folder", + "Get File Metadata": "Get File Metadata", + "List File Revisions": "List File Revisions", + "Restore File Revision": "Restore File Revision", + "Create Copy Reference": "Create Copy Reference", + "Save Copy Reference": "Save Copy Reference", + "Lock File": "Lock File", + "Unlock File": "Unlock File", + "Get File Lock": "Get File Lock", + "Create Upload Link": "Create Upload Link", + "Download Folder as Zip": "Download Folder as Zip", + "Get File Preview": "Get File Preview", + "Get File Thumbnail": "Get File Thumbnail", + "Get Thumbnails (Batch)": "Get Thumbnails (Batch)", + "Save URL to Dropbox": "Save URL to Dropbox", + "Get Save URL Status": "Get Save URL Status", + "Start Copy Batch": "Start Copy Batch", + "Get Copy Batch Status": "Get Copy Batch Status", + "Start Move Batch": "Start Move Batch", + "Get Move Batch Status": "Get Move Batch Status", + "Start Delete Batch": "Start Delete Batch", + "Get Delete Batch Status": "Get Delete Batch Status", + "Start Create Folder Batch": "Start Create Folder Batch", + "Get Create Folder Batch Status": "Get Create Folder Batch Status", + "Create Property Template": "Create Property Template", + "Get Property Template": "Get Property Template", + "List Property Templates": "List Property Templates", + "Update Property Template": "Update Property Template", + "Add File Properties": "Add File Properties", + "Overwrite File Properties": "Overwrite File Properties", + "Update File Properties": "Update File Properties", + "Remove File Properties": "Remove File Properties", + "Search File Properties": "Search File Properties", + "Copy Entry": "Copy Entry", + "Move Entry": "Move Entry", + "Delete Entry": "Delete Entry", "Custom API Call": "Custom API Call", "Search for files and folders": "Search for files and folders", "Create a new text file from text input": "Create a new text file from text input", @@ -27,6 +62,41 @@ "Move a folder": "Move a folder", "Copy a folder": "Copy a folder", "List the contents of a folder": "List the contents of a folder", + "Get metadata for a file or folder": "Get metadata for a file or folder", + "List the stored revisions of a file": "List the stored revisions of a file", + "Restore a file to a previous revision": "Restore a file to a previous revision", + "Create a copy reference for a file or folder": "Create a copy reference for a file or folder", + "Save a copy reference into this account": "Save a copy reference into this account", + "Temporarily lock a file against edits": "Temporarily lock a file against edits", + "Release a lock on a file": "Release a lock on a file", + "Check whether a file is locked": "Check whether a file is locked", + "Create a temporary link for uploading a file": "Create a temporary link for uploading a file", + "Download a folder as a zip archive": "Download a folder as a zip archive", + "Get a PDF or HTML preview of a document": "Get a PDF or HTML preview of a document", + "Get a thumbnail image for a file": "Get a thumbnail image for a file", + "Get thumbnails for several files at once": "Get thumbnails for several files at once", + "Download a URL into Dropbox": "Download a URL into Dropbox", + "Check the status of a Save URL job": "Check the status of a Save URL job", + "Copy many files or folders in one job": "Copy many files or folders in one job", + "Check the status of a copy batch job": "Check the status of a copy batch job", + "Move many files or folders in one job": "Move many files or folders in one job", + "Check the status of a move batch job": "Check the status of a move batch job", + "Delete many files or folders in one job": "Delete many files or folders in one job", + "Check the status of a delete batch job": "Check the status of a delete batch job", + "Create many folders in one job": "Create many folders in one job", + "Check the status of a create folder batch job": "Check the status of a create folder batch job", + "Create a custom metadata template": "Create a custom metadata template", + "Get a custom metadata template": "Get a custom metadata template", + "List custom metadata template IDs": "List custom metadata template IDs", + "Rename a template or add fields to it": "Rename a template or add fields to it", + "Attach custom metadata to a file": "Attach custom metadata to a file", + "Replace custom metadata on a file": "Replace custom metadata on a file", + "Change individual custom metadata fields": "Change individual custom metadata fields", + "Detach custom metadata from a file": "Detach custom metadata from a file", + "Find files by custom metadata value": "Find files by custom metadata value", + "Copy a file or folder": "Copy a file or folder", + "Move or rename a file or folder": "Move or rename a file or folder", + "Delete a file or folder": "Delete a file or folder", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "Query": "Query", "Path": "Path", @@ -48,14 +118,40 @@ "Allow Ownership Transfer": "Allow Ownership Transfer", "Recursive": "Recursive", "Limit": "Limit", + "Include Deleted": "Include Deleted", + "Include Media Info": "Include Media Info", + "Include Shared Members Flag": "Include Shared Members Flag", + "Property Template IDs": "Property Template IDs", + "Mode": "Mode", + "Revision": "Revision", + "Copy Reference": "Copy Reference", + "Destination Path": "Destination Path", + "Write Mode": "Write Mode", + "Duration (seconds)": "Duration (seconds)", + "Folder Path": "Folder Path", + "Format": "Format", + "Size": "Size", + "Paths": "Paths", + "URL": "URL", + "Async Job ID": "Async Job ID", + "Entries": "Entries", + "Template Name": "Template Name", + "Description": "Description", + "Fields": "Fields", + "Template ID": "Template ID", + "Fields to Add": "Fields to Add", + "Fields to Add or Update": "Fields to Add or Update", + "Fields to Remove": "Fields to Remove", + "Template IDs": "Template IDs", + "Field Name": "Field Name", "Method": "Method", "Headers": "Headers", "Query Parameters": "Query Parameters", "Body Type": "Body Type", "Body": "Body", - "Response is Binary ?": "Response is Binary ?", - "No Error on Failure": "No Error on Failure", - "Timeout (in seconds)": "Timeout (in seconds)", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", "Follow redirects": "Follow redirects", "The search string. Must be at least 3 characters.": "The search string. Must be at least 3 characters.", "The path to search in. If not specified, the search is performed from the root.": "The path to search in. If not specified, the search is performed from the root.", @@ -92,22 +188,103 @@ "The path of the folder to be listed (e.g. /folder1). Use an empty string for the root folder.": "The path of the folder to be listed (e.g. /folder1). Use an empty string for the root folder.", "If set to true, the list folder operation will be applied recursively to all subfolders and the response will contain contents of all subfolders.": "If set to true, the list folder operation will be applied recursively to all subfolders and the response will contain contents of all subfolders.", "The maximum number of results to return (between 1 and 2000). Default is 2000 if not specified.": "The maximum number of results to return (between 1 and 2000). Default is 2000 if not specified.", + "The file or folder to inspect. Accepts a path (/folder/file.txt), an id (id:abc123) or a revision (rev:a1c10ce0dd78).": "The file or folder to inspect. Accepts a path (/folder/file.txt), an id (id:abc123) or a revision (rev:a1c10ce0dd78).", + "Return metadata for a deleted entry instead of failing when the path no longer exists.": "Return metadata for a deleted entry instead of failing when the path no longer exists.", + "Include photo and video metadata when available.": "Include photo and video metadata when available.", + "Include whether the entry has explicit shared members assigned.": "Include whether the entry has explicit shared members assigned.", + "Return the custom metadata stored under these templates, each in the form ptid:xxxx. Leave empty to omit custom metadata.": "Return the custom metadata stored under these templates, each in the form ptid:xxxx. Leave empty to omit custom metadata.", + "The file whose revisions to list. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file whose revisions to list. Accepts a path (/folder/file.txt) or an id (id:abc123).", + "Path returns revisions of whatever currently sits at the path; ID follows the same file across moves and renames.": "Path returns revisions of whatever currently sits at the path; ID follows the same file across moves and renames.", + "Maximum revisions to return, between 1 and 100.": "Maximum revisions to return, between 1 and 100.", + "The file to restore, as a path (/folder/file.txt). This endpoint does not accept id: or rev: values.": "The file to restore, as a path (/folder/file.txt). This endpoint does not accept id: or rev: values.", + "The revision to restore the file to. Obtain it from List File Revisions.": "The revision to restore the file to. Obtain it from List File Revisions.", + "The file or folder to reference. Accepts a path (/folder/file.txt), an id (id:abc123) or a revision (rev:a1c10ce0dd78).": "The file or folder to reference. Accepts a path (/folder/file.txt), an id (id:abc123) or a revision (rev:a1c10ce0dd78).", + "The reference string returned by Create Copy Reference.": "The reference string returned by Create Copy Reference.", + "Where to save the entry, as a path (/folder/file.txt). This endpoint does not accept id: or ns: values.": "Where to save the entry, as a path (/folder/file.txt). This endpoint does not accept id: or ns: values.", + "The file to lock. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.": "The file to lock. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.", + "The file to unlock. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.": "The file to unlock. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.", + "The file to check. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.": "The file to check. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.", + "Where the uploaded file will be written (e.g. /folder/file.txt). Accepts a path or an id (id:abc123).": "Where the uploaded file will be written (e.g. /folder/file.txt). Accepts a path or an id (id:abc123).", + "Add keeps any existing file and renames on conflict; Overwrite replaces it.": "Add keeps any existing file and renames on conflict; Overwrite replaces it.", + "Let Dropbox rename the upload if the path conflicts.": "Let Dropbox rename the upload if the path conflicts.", + "How long the link stays valid, between 60 and 14400 seconds. Defaults to 14400.": "How long the link stays valid, between 60 and 14400 seconds. Defaults to 14400.", + "The folder to download (e.g. /reports). Accepts a path, an id (id:abc123) or a revision (rev:a1c10ce0dd78).": "The folder to download (e.g. /reports). Accepts a path, an id (id:abc123) or a revision (rev:a1c10ce0dd78).", + "The document to preview. Accepts a path (/folder/report.docx), an id (id:abc123) or a revision (rev:a1c10ce0dd78).": "The document to preview. Accepts a path (/folder/report.docx), an id (id:abc123) or a revision (rev:a1c10ce0dd78).", + "The image file to thumbnail. Accepts a path (/folder/photo.jpg), an id (id:abc123) or a revision (rev:a1c10ce0dd78).": "The image file to thumbnail. Accepts a path (/folder/photo.jpg), an id (id:abc123) or a revision (rev:a1c10ce0dd78).", + "Strict keeps the requested dimensions exactly; the bestfit modes preserve the aspect ratio.": "Strict keeps the requested dimensions exactly; the bestfit modes preserve the aspect ratio.", + "The image files to thumbnail, at most 25. Each accepts a path (/folder/photo.jpg), an id (id:abc123) or a revision (rev:a1c10ce0dd78).": "The image files to thumbnail, at most 25. Each accepts a path (/folder/photo.jpg), an id (id:abc123) or a revision (rev:a1c10ce0dd78).", + "Where to save the file, as a path (/folder/file.pdf). This endpoint does not accept id: or ns: values.": "Where to save the file, as a path (/folder/file.pdf). This endpoint does not accept id: or ns: values.", + "The publicly reachable URL for Dropbox to download.": "The publicly reachable URL for Dropbox to download.", + "The job id returned by Save URL to Dropbox.": "The job id returned by Save URL to Dropbox.", + "Each entry needs a From Path and a To Path. Both accept a path (/folder/file.txt) or an id (id:abc123). At most 1000 entries.": "Each entry needs a From Path and a To Path. Both accept a path (/folder/file.txt) or an id (id:abc123). At most 1000 entries.", + "Let Dropbox rename an entry when its destination conflicts.": "Let Dropbox rename an entry when its destination conflicts.", + "The job id returned by Start Copy Batch.": "The job id returned by Start Copy Batch.", + "The job id returned by Start Move Batch.": "The job id returned by Start Move Batch.", + "The entries to delete, at most 1000. Each accepts a path (/folder/file.txt) or an id (id:abc123).": "The entries to delete, at most 1000. Each accepts a path (/folder/file.txt) or an id (id:abc123).", + "The job id returned by Start Delete Batch.": "The job id returned by Start Delete Batch.", + "The folders to create, at most 10000. Each must be a path such as /reports/2026; this endpoint does not accept id: values.": "The folders to create, at most 10000. Each must be a path such as /reports/2026; this endpoint does not accept id: values.", + "Let Dropbox rename a folder when its path conflicts.": "Let Dropbox rename a folder when its path conflicts.", + "The job id returned by Start Create Folder Batch.": "The job id returned by Start Create Folder Batch.", + "Display name for the template.": "Display name for the template.", + "What this template is for, up to 1024 bytes.": "What this template is for, up to 1024 bytes.", + "The named fields this template defines. All field values are stored as text.": "The named fields this template defines. All field values are stored as text.", + "The template id, in the form ptid:xxxx. Obtain it from List Property Templates.": "The template id, in the form ptid:xxxx. Obtain it from List Property Templates.", + "Leave blank to keep the current name.": "Leave blank to keep the current name.", + "Leave blank to keep the current description.": "Leave blank to keep the current description.", + "New fields to append. Existing fields cannot be removed or renamed.": "New fields to append. Existing fields cannot be removed or renamed.", + "The file to annotate. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file to annotate. Accepts a path (/folder/file.txt) or an id (id:abc123).", + "The template defining these fields, in the form ptid:xxxx. Obtain it from List Property Templates.": "The template defining these fields, in the form ptid:xxxx. Obtain it from List Property Templates.", + "Name and value for each property. Every name must exist in the template.": "Name and value for each property. Every name must exist in the template.", + "The complete set of name and value pairs. Any field omitted here is removed from the file.": "The complete set of name and value pairs. Any field omitted here is removed from the file.", + "The file to update. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file to update. Accepts a path (/folder/file.txt) or an id (id:abc123).", + "The template whose fields are being changed, in the form ptid:xxxx.": "The template whose fields are being changed, in the form ptid:xxxx.", + "Fields to set. Fields not listed here keep their values.": "Fields to set. Fields not listed here keep their values.", + "Names of fields to clear from the file.": "Names of fields to clear from the file.", + "The file to clear. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file to clear. Accepts a path (/folder/file.txt) or an id (id:abc123).", + "The templates whose property groups should be removed, each in the form ptid:xxxx.": "The templates whose property groups should be removed, each in the form ptid:xxxx.", + "The value to match in the named field.": "The value to match in the named field.", + "The template field to search, as named when the template was created.": "The template field to search, as named when the template was created.", + "The file or folder to copy. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file or folder to copy. Accepts a path (/folder/file.txt) or an id (id:abc123).", + "The destination path, for example /folder2/file.txt.": "The destination path, for example /folder2/file.txt.", + "Let Dropbox rename the copy when the destination conflicts.": "Let Dropbox rename the copy when the destination conflicts.", + "The file or folder to move. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file or folder to move. Accepts a path (/folder/file.txt) or an id (id:abc123).", + "The destination path, for example /folder2/renamed.txt.": "The destination path, for example /folder2/renamed.txt.", + "Let Dropbox rename the entry when the destination conflicts.": "Let Dropbox rename the entry when the destination conflicts.", + "The file or folder to delete. Accepts a path (/folder/file.txt) or an id (id:abc123).": "The file or folder to delete. Accepts a path (/folder/file.txt) or an id (id:abc123).", "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", + "Appended to the URL as ?key=value.": "Appended to the URL as ?key=value.", "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", + "On a failed request, output the error instead of failing the step.": "On a failed request, output the error instead of failing the step.", + "Seconds to wait for a response. Empty: up to the flow limit (10 min).": "Seconds to wait for a response. Empty: up to the flow limit (10 min).", + "Follow 3xx redirects instead of returning them as the response.": "Follow 3xx redirects instead of returning them as the response.", "Relevance": "Relevance", "Modified Time": "Modified Time", "Active": "Active", "Deleted": "Deleted", + "ID": "ID", + "Add": "Add", + "Overwrite": "Overwrite", + "JPEG": "JPEG", + "PNG": "PNG", + "WebP": "WebP", + "32x32": "32x32", + "64x64": "64x64", + "128x128": "128x128", + "256x256": "256x256", + "480x320": "480x320", + "640x480": "640x480", + "960x640": "960x640", + "1024x768": "1024x768", + "2048x1536": "2048x1536", + "Strict": "Strict", + "Best Fit": "Best Fit", + "Fit One, Best Fit": "Fit One, Best Fit", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw", "New Folder": "New Folder", "Triggers when a new folder is created inside a watched Dropbox folder.": "Triggers when a new folder is created inside a watched Dropbox folder.", "Watched Folder Path": "Watched Folder Path", diff --git a/packages/pieces/community/dropbox/src/index.ts b/packages/pieces/community/dropbox/src/index.ts index 1895158d169c..45658d681aa2 100644 --- a/packages/pieces/community/dropbox/src/index.ts +++ b/packages/pieces/community/dropbox/src/index.ts @@ -17,10 +17,36 @@ import { dropboxSearch } from './lib/actions/search'; import { dropboxUploadFile } from './lib/actions/upload-file'; import { dropboxDownloadFile } from './lib/actions/download-file'; import { dropboxAuth } from './lib/auth'; +import { dropboxGetFileMetadata } from './lib/actions/get-file-metadata'; +import { dropboxListFileRevisions } from './lib/actions/list-file-revisions'; +import { dropboxRestoreFile } from './lib/actions/restore-file'; +import { dropboxCreateCopyReference } from './lib/actions/create-copy-reference'; +import { dropboxSaveCopyReference } from './lib/actions/save-copy-reference'; +import { dropboxLockFile } from './lib/actions/lock-file'; +import { dropboxUnlockFile } from './lib/actions/unlock-file'; +import { dropboxGetFileLock } from './lib/actions/get-file-lock'; +import { dropboxCreateUploadLink } from './lib/actions/create-upload-link'; +import { dropboxDownloadFolderAsZip } from './lib/actions/download-folder-as-zip'; +import { dropboxGetFilePreview } from './lib/actions/get-file-preview'; +import { dropboxGetFileThumbnail } from './lib/actions/get-file-thumbnail'; +import { dropboxGetThumbnailsBatch } from './lib/actions/get-thumbnails-batch'; +import { dropboxSaveUrl } from './lib/actions/save-url'; +import { dropboxGetSaveUrlStatus } from './lib/actions/get-save-url-status'; +import { dropboxStartCopyBatch } from './lib/actions/start-copy-batch'; +import { dropboxGetCopyBatchStatus } from './lib/actions/get-copy-batch-status'; +import { dropboxStartMoveBatch } from './lib/actions/start-move-batch'; +import { dropboxGetMoveBatchStatus } from './lib/actions/get-move-batch-status'; +import { dropboxStartDeleteBatch } from './lib/actions/start-delete-batch'; +import { dropboxGetDeleteBatchStatus } from './lib/actions/get-delete-batch-status'; +import { dropboxStartCreateFolderBatch } from './lib/actions/start-create-folder-batch'; +import { dropboxGetCreateFolderBatchStatus } from './lib/actions/get-create-folder-batch-status'; +import { dropboxCopyEntry } from './lib/actions/copy-entry'; +import { dropboxMoveEntry } from './lib/actions/move-entry'; +import { dropboxDeleteEntry } from './lib/actions/delete-entry'; import { dropboxNewFolder } from './lib/triggers/new-folder'; export const dropbox = createPiece({ - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.86.4', logoUrl: 'https://cdn.activepieces.com/pieces/dropbox.png', actions: [ dropboxSearch, @@ -36,6 +62,32 @@ export const dropbox = createPiece({ dropboxMoveFolder, dropboxCopyFolder, dropboxListAFolder, + dropboxGetFileMetadata, + dropboxListFileRevisions, + dropboxRestoreFile, + dropboxCreateCopyReference, + dropboxSaveCopyReference, + dropboxLockFile, + dropboxUnlockFile, + dropboxGetFileLock, + dropboxCreateUploadLink, + dropboxDownloadFolderAsZip, + dropboxGetFilePreview, + dropboxGetFileThumbnail, + dropboxGetThumbnailsBatch, + dropboxSaveUrl, + dropboxGetSaveUrlStatus, + dropboxStartCopyBatch, + dropboxGetCopyBatchStatus, + dropboxStartMoveBatch, + dropboxGetMoveBatchStatus, + dropboxStartDeleteBatch, + dropboxGetDeleteBatchStatus, + dropboxStartCreateFolderBatch, + dropboxGetCreateFolderBatchStatus, + dropboxCopyEntry, + dropboxMoveEntry, + dropboxDeleteEntry, createCustomApiCallAction({ baseUrl: () => 'https://api.dropboxapi.com/2', auth: dropboxAuth, diff --git a/packages/pieces/community/dropbox/src/lib/actions/copy-entry.ts b/packages/pieces/community/dropbox/src/lib/actions/copy-entry.ts new file mode 100644 index 000000000000..3ac66a534be8 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/copy-entry.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { entryOperationOutputSchema } from '../output-schemas'; + +export const dropboxCopyEntry = createAction({ + auth: dropboxAuth, + name: 'copy_dropbox_entry', + classification: 'WRITE', + displayName: 'Copy Entry', + description: 'Copy a file or folder', + audience: 'ai', + aiMetadata: { + description: + 'Copies a Dropbox file or folder to a new path, leaving the original in place. Handles both files and folders, so no separate action is needed for each. Use Start Copy Batch for many entries at once. Not idempotent: each call creates another copy, so repeating it conflicts or, with autorename, produces duplicates.', + idempotent: false, + }, + outputSchema: entryOperationOutputSchema, + props: { + from_path: Property.ShortText({ + displayName: 'From Path', + description: + 'The file or folder to copy. Accepts a path (/folder/file.txt) or an id (id:abc123).', + required: true, + }), + to_path: Property.ShortText({ + displayName: 'To Path', + description: 'The destination path, for example /folder2/file.txt.', + required: true, + }), + autorename: Property.Checkbox({ + displayName: 'Auto Rename', + description: 'Let Dropbox rename the copy when the destination conflicts.', + defaultValue: false, + required: false, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/copy_v2', + body: { + from_path: context.propsValue.from_path, + to_path: context.propsValue.to_path, + autorename: context.propsValue.autorename ?? false, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts b/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts index a49f5868ac22..8e3a3c3161c4 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts @@ -12,7 +12,7 @@ export const dropboxCopyFile = createAction({ name: 'copy_dropbox_file', classification: 'WRITE', description: 'Copy a file', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Copies the file at the source path to a new destination path within Dropbox, leaving the original in place; optionally autorenames on conflict. Use to duplicate a single file. Not idempotent: each call creates a copy, so repeating it errors on conflict or, with autorename, produces additional duplicates.', idempotent: false }, displayName: 'Copy file', outputSchema: fileMetadataOutputSchema, diff --git a/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts index 3230d77aef57..97e28e5521ae 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts @@ -12,7 +12,7 @@ export const dropboxCopyFolder = createAction({ name: 'copy_dropbox_folder', classification: 'WRITE', description: 'Copy a folder', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Copies the folder at the source path, including its contents, to a new destination path within Dropbox, leaving the original in place; optionally autorenames on conflict. Use to duplicate an entire directory. Not idempotent: each call creates a copy, so repeating it errors on conflict or, with autorename, produces additional duplicates.', idempotent: false }, displayName: 'Copy folder', outputSchema: folderMetadataOutputSchema, diff --git a/packages/pieces/community/dropbox/src/lib/actions/create-copy-reference.ts b/packages/pieces/community/dropbox/src/lib/actions/create-copy-reference.ts new file mode 100644 index 000000000000..2c2130666ebd --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/create-copy-reference.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { copyReferenceOutputSchema } from '../output-schemas'; + +export const dropboxCreateCopyReference = createAction({ + auth: dropboxAuth, + name: 'create_dropbox_copy_reference', + classification: 'WRITE', + displayName: 'Create Copy Reference', + description: 'Create a copy reference for a file or folder', + audience: 'ai', + aiMetadata: { + description: + 'Creates a copy reference for a Dropbox file or folder, which Save Copy Reference can then use to copy it into a different Dropbox account. Use only for cross-account copies; within one account use Copy Dropbox Entry instead. The response carries the reference and its expiry date. Not idempotent: each call mints a new reference.', + idempotent: false, + }, + outputSchema: copyReferenceOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file or folder to reference. Accepts a path (/folder/file.txt), an id (id:abc123) or a revision (rev:a1c10ce0dd78).', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/copy_reference/get', + body: { path: context.propsValue.path }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/create-upload-link.ts b/packages/pieces/community/dropbox/src/lib/actions/create-upload-link.ts new file mode 100644 index 000000000000..ecf9e78a11a2 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/create-upload-link.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { uploadLinkOutputSchema } from '../output-schemas'; + +export const dropboxCreateUploadLink = createAction({ + auth: dropboxAuth, + name: 'create_dropbox_upload_link', + classification: 'WRITE', + displayName: 'Create Upload Link', + description: 'Create a temporary link for uploading a file', + audience: 'ai', + aiMetadata: { + description: + 'Creates a temporary URL that accepts a file upload to a fixed Dropbox path without further authentication. Use to hand an upload destination to an external system; use Upload File instead when the flow already holds the file. Not idempotent: each call mints a new link with its own expiry.', + idempotent: false, + }, + outputSchema: uploadLinkOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Destination Path', + description: + 'Where the uploaded file will be written (e.g. /folder/file.txt). Accepts a path or an id (id:abc123).', + required: true, + }), + mode: Property.StaticDropdown({ + displayName: 'Write Mode', + description: + 'Add keeps any existing file and renames on conflict; Overwrite replaces it.', + required: false, + defaultValue: 'add', + options: { + options: [ + { label: 'Add', value: 'add' }, + { label: 'Overwrite', value: 'overwrite' }, + ], + }, + }), + autorename: Property.Checkbox({ + displayName: 'Auto Rename', + description: 'Let Dropbox rename the upload if the path conflicts.', + defaultValue: false, + required: false, + }), + duration: Property.Number({ + displayName: 'Duration (seconds)', + description: + 'How long the link stays valid, between 60 and 14400 seconds. Defaults to 14400.', + defaultValue: 14400, + required: false, + }), + }, + async run(context) { + const duration = context.propsValue.duration ?? 14400; + if (duration < 60 || duration > 14400) { + throw new Error('Duration must be between 60 and 14400 seconds.'); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/get_temporary_upload_link', + body: { + commit_info: { + path: context.propsValue.path, + mode: context.propsValue.mode ?? 'add', + autorename: context.propsValue.autorename ?? false, + }, + duration, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/delete-entry.ts b/packages/pieces/community/dropbox/src/lib/actions/delete-entry.ts new file mode 100644 index 000000000000..123e5cc3977e --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/delete-entry.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { entryOperationOutputSchema } from '../output-schemas'; + +export const dropboxDeleteEntry = createAction({ + auth: dropboxAuth, + name: 'delete_dropbox_entry', + classification: 'DESTRUCTIVE', + displayName: 'Delete Entry', + description: 'Delete a file or folder', + audience: 'ai', + aiMetadata: { + description: + 'Deletes a Dropbox file or folder, sending it to trash where it stays restorable rather than erasing it. Deleting a folder also deletes everything inside it. Handles both files and folders, so no separate action is needed for each. Not idempotent: a second call fails because the path is already gone.', + idempotent: false, + }, + outputSchema: entryOperationOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file or folder to delete. Accepts a path (/folder/file.txt) or an id (id:abc123).', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/delete_v2', + body: { path: context.propsValue.path }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts b/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts index b9f1a1dffa08..8d3cd66e1329 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts @@ -12,7 +12,7 @@ export const dropboxDeleteFile = createAction({ name: 'delete_dropbox_file', classification: 'DESTRUCTIVE', description: 'Delete a file', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Permanently deletes the file at the given Dropbox path. Use to remove a specific file an agent has resolved the path for. Effectively idempotent on the end state once the path is gone, but a repeat call fails because the path no longer exists; treat as a destructive, non-recoverable mutation.', idempotent: false }, displayName: 'Delete file', outputSchema: fileMetadataOutputSchema, @@ -25,10 +25,6 @@ export const dropboxDeleteFile = createAction({ }), }, async run(context) { - const params = { - path: context.propsValue.path, - }; - const result = await httpClient.sendRequest({ method: HttpMethod.POST, url: `https://api.dropboxapi.com/2/files/delete_v2`, diff --git a/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts index 954d9f06c3df..62053a34f18e 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts @@ -12,7 +12,7 @@ export const dropboxDeleteFolder = createAction({ name: 'delete_dropbox_folder', classification: 'DESTRUCTIVE', description: 'Delete a folder', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Permanently deletes the folder at the given Dropbox path along with all of its contents. Use to remove an entire directory. Effectively idempotent on the end state once the path is gone, but a repeat call fails because the path no longer exists; treat as a destructive, non-recoverable mutation.', idempotent: false }, displayName: 'Delete folder', outputSchema: folderMetadataOutputSchema, diff --git a/packages/pieces/community/dropbox/src/lib/actions/download-folder-as-zip.ts b/packages/pieces/community/dropbox/src/lib/actions/download-folder-as-zip.ts new file mode 100644 index 000000000000..c972a19ef241 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/download-folder-as-zip.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { zipDownloadOutputSchema } from '../output-schemas'; + +export const dropboxDownloadFolderAsZip = createAction({ + auth: dropboxAuth, + name: 'download_dropbox_folder_as_zip', + classification: 'READ', + displayName: 'Download Folder as Zip', + description: 'Download a folder as a zip archive', + audience: 'ai', + aiMetadata: { + description: + 'Downloads an entire Dropbox folder as a single zip archive and returns it as a file object. Use to retrieve a whole folder in one step instead of listing it and downloading each file. The folder must be under 20 GB and contain fewer than 10,000 files. Read-only.', + idempotent: true, + }, + outputSchema: zipDownloadOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Folder Path', + description: + 'The folder to download (e.g. /reports). Accepts a path, an id (id:abc123) or a revision (rev:a1c10ce0dd78).', + required: true, + }), + }, + async run(context) { + const folderName = + (context.propsValue.path.match(/[^/]+$/) ?? ['dropbox-folder'])[0]; + const { data, result } = await dropboxCommon.download<{ + metadata?: unknown; + }>({ + auth: context.auth.access_token, + path: '/files/download_zip', + arg: { path: context.propsValue.path }, + }); + return { + file: await context.files.write({ + fileName: `${folderName}.zip`, + data, + }), + metadata: result?.metadata, + }; + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-copy-batch-status.ts b/packages/pieces/community/dropbox/src/lib/actions/get-copy-batch-status.ts new file mode 100644 index 000000000000..492a36df0917 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-copy-batch-status.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { relocationStatusOutputSchema } from '../output-schemas'; + +export const dropboxGetCopyBatchStatus = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_copy_batch_status', + classification: 'READ', + displayName: 'Get Copy Batch Status', + description: 'Check the status of a copy batch job', + audience: 'ai', + aiMetadata: { + description: + 'Reads the status of a copy batch started by Start Copy Batch, returning in_progress or complete with one result per entry. Dropbox reports no overall failure for this job, so check each entry: a batch where every entry failed still reports complete. Call once per check rather than looping. Read-only.', + idempotent: true, + }, + outputSchema: relocationStatusOutputSchema, + props: { + async_job_id: Property.ShortText({ + displayName: 'Async Job ID', + description: 'The job id returned by Start Copy Batch.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/copy_batch/check_v2', + body: { async_job_id: context.propsValue.async_job_id }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-create-folder-batch-status.ts b/packages/pieces/community/dropbox/src/lib/actions/get-create-folder-batch-status.ts new file mode 100644 index 000000000000..37ab3e675df1 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-create-folder-batch-status.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { createFolderBatchStatusOutputSchema } from '../output-schemas'; + +export const dropboxGetCreateFolderBatchStatus = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_create_folder_batch_status', + classification: 'READ', + displayName: 'Get Create Folder Batch Status', + description: 'Check the status of a create folder batch job', + audience: 'ai', + aiMetadata: { + description: + 'Reads the status of a folder batch started by Start Create Folder Batch, returning in_progress, complete with one result per folder, or failed. Check each entry as well as the overall status, since individual folders can fail inside a complete job. Call once per check rather than looping. Read-only.', + idempotent: true, + }, + outputSchema: createFolderBatchStatusOutputSchema, + props: { + async_job_id: Property.ShortText({ + displayName: 'Async Job ID', + description: 'The job id returned by Start Create Folder Batch.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/create_folder_batch/check', + body: { async_job_id: context.propsValue.async_job_id }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-delete-batch-status.ts b/packages/pieces/community/dropbox/src/lib/actions/get-delete-batch-status.ts new file mode 100644 index 000000000000..b8fa06c33204 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-delete-batch-status.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { deleteBatchStatusOutputSchema } from '../output-schemas'; + +export const dropboxGetDeleteBatchStatus = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_delete_batch_status', + classification: 'READ', + displayName: 'Get Delete Batch Status', + description: 'Check the status of a delete batch job', + audience: 'ai', + aiMetadata: { + description: + 'Reads the status of a delete batch started by Start Delete Batch, returning in_progress, complete with one result per entry, or failed. Check each entry as well as the overall status, since individual paths can fail inside a complete job. Call once per check rather than looping. Read-only.', + idempotent: true, + }, + outputSchema: deleteBatchStatusOutputSchema, + props: { + async_job_id: Property.ShortText({ + displayName: 'Async Job ID', + description: 'The job id returned by Start Delete Batch.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/delete_batch/check', + body: { async_job_id: context.propsValue.async_job_id }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts b/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts index f46713012c68..3a17231f6447 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts @@ -24,10 +24,6 @@ export const dropboxGetFileLink = createAction({ }), }, async run(context) { - const params = { - path: context.propsValue.path, - }; - const result = await httpClient.sendRequest({ method: HttpMethod.POST, url: `https://api.dropboxapi.com/2/files/get_temporary_link`, diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-file-lock.ts b/packages/pieces/community/dropbox/src/lib/actions/get-file-lock.ts new file mode 100644 index 000000000000..b556ee0780c5 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-file-lock.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { lockResultOutputSchema } from '../output-schemas'; + +export const dropboxGetFileLock = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_file_lock', + classification: 'READ', + displayName: 'Get File Lock', + description: 'Check whether a file is locked', + audience: 'ai', + aiMetadata: { + description: + 'Reports whether a Dropbox file is currently locked, who holds the lock and when it was taken. Use to check before editing a shared file rather than attempting a write and handling the failure. Read-only.', + idempotent: true, + }, + outputSchema: lockResultOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file to check. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.', + required: true, + }), + }, + async run(context) { + const response = await dropboxCommon.rpc<{ entries?: unknown }>({ + auth: context.auth.access_token, + path: '/files/get_file_lock_batch', + body: { entries: [{ path: context.propsValue.path }] }, + }); + return dropboxCommon.unwrapEntry({ entries: response.entries, action: 'read the file lock' }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-file-metadata.ts b/packages/pieces/community/dropbox/src/lib/actions/get-file-metadata.ts new file mode 100644 index 000000000000..9ec9ce4d9c8a --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-file-metadata.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { entryMetadataOutputSchema } from '../output-schemas'; + +export const dropboxGetFileMetadata = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_file_metadata', + classification: 'READ', + displayName: 'Get File Metadata', + description: 'Get metadata for a file or folder', + audience: 'ai', + aiMetadata: { + description: + 'Returns metadata for a single Dropbox file or folder: name, id, path, size, revision and modification times. Use to inspect one known entry, or to resolve a path into the id and rev that other actions require; use Search Dropbox instead when looking for entries by name. Read-only.', + idempotent: true, + }, + outputSchema: entryMetadataOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file or folder to inspect. Accepts a path (/folder/file.txt), an id (id:abc123) or a revision (rev:a1c10ce0dd78).', + required: true, + }), + include_deleted: Property.Checkbox({ + displayName: 'Include Deleted', + description: + 'Return metadata for a deleted entry instead of failing when the path no longer exists.', + defaultValue: false, + required: false, + }), + include_media_info: Property.Checkbox({ + displayName: 'Include Media Info', + description: 'Include photo and video metadata when available.', + defaultValue: false, + required: false, + }), + include_has_explicit_shared_members: Property.Checkbox({ + displayName: 'Include Shared Members Flag', + description: + 'Include whether the entry has explicit shared members assigned.', + defaultValue: false, + required: false, + }), + }, + async run(context) { + const body: Record = { + path: context.propsValue.path, + include_deleted: context.propsValue.include_deleted ?? false, + include_media_info: context.propsValue.include_media_info ?? false, + include_has_explicit_shared_members: + context.propsValue.include_has_explicit_shared_members ?? false, + }; + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/get_metadata', + body, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-file-preview.ts b/packages/pieces/community/dropbox/src/lib/actions/get-file-preview.ts new file mode 100644 index 000000000000..5b5314b5fb3f --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-file-preview.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { filePreviewOutputSchema } from '../output-schemas'; + +export const dropboxGetFilePreview = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_file_preview', + classification: 'READ', + displayName: 'Get File Preview', + description: 'Get a PDF or HTML preview of a document', + audience: 'ai', + aiMetadata: { + description: + 'Returns a PDF or HTML preview of a Dropbox document and its metadata. Supports office and text formats such as doc, docx, xls, xlsx, ppt, pptx, rtf and csv; use Get File Thumbnail for images. Read-only.', + idempotent: true, + }, + outputSchema: filePreviewOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The document to preview. Accepts a path (/folder/report.docx), an id (id:abc123) or a revision (rev:a1c10ce0dd78).', + required: true, + }), + }, + async run(context) { + const baseName = (context.propsValue.path.match(/[^/]+$/) ?? ['preview'])[0]; + const { data, result, contentType } = await dropboxCommon.download({ + auth: context.auth.access_token, + path: '/files/get_preview', + arg: { path: context.propsValue.path }, + }); + const extension = dropboxCommon.previewExtensionFor(contentType); + return { + file: await context.files.write({ + fileName: `${baseName.replace(/\.[^.]+$/, '')}.${extension}`, + data, + }), + contentType, + metadata: result, + }; + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-file-thumbnail.ts b/packages/pieces/community/dropbox/src/lib/actions/get-file-thumbnail.ts new file mode 100644 index 000000000000..4b289a8b6830 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-file-thumbnail.ts @@ -0,0 +1,94 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { fileThumbnailOutputSchema } from '../output-schemas'; + +export const dropboxGetFileThumbnail = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_file_thumbnail', + classification: 'READ', + displayName: 'Get File Thumbnail', + description: 'Get a thumbnail image for a file', + audience: 'ai', + aiMetadata: { + description: + 'Returns a thumbnail image for a single Dropbox image file as a file object, with its metadata. Supports jpg, jpeg, png, tiff, tif, gif, webp, ppm and bmp under 20 MB; use Get Thumbnails (Batch) for many files at once. Read-only.', + idempotent: true, + }, + outputSchema: fileThumbnailOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The image file to thumbnail. Accepts a path (/folder/photo.jpg), an id (id:abc123) or a revision (rev:a1c10ce0dd78).', + required: true, + }), + format: Property.StaticDropdown({ + displayName: 'Format', + required: false, + defaultValue: 'jpeg', + options: { + options: [ + { label: 'JPEG', value: 'jpeg' }, + { label: 'PNG', value: 'png' }, + { label: 'WebP', value: 'webp' }, + ], + }, + }), + size: Property.StaticDropdown({ + displayName: 'Size', + required: false, + defaultValue: 'w64h64', + options: { + options: [ + { label: '32x32', value: 'w32h32' }, + { label: '64x64', value: 'w64h64' }, + { label: '128x128', value: 'w128h128' }, + { label: '256x256', value: 'w256h256' }, + { label: '480x320', value: 'w480h320' }, + { label: '640x480', value: 'w640h480' }, + { label: '960x640', value: 'w960h640' }, + { label: '1024x768', value: 'w1024h768' }, + { label: '2048x1536', value: 'w2048h1536' }, + ], + }, + }), + mode: Property.StaticDropdown({ + displayName: 'Mode', + description: + 'Strict keeps the requested dimensions exactly; the bestfit modes preserve the aspect ratio.', + required: false, + defaultValue: 'strict', + options: { + options: [ + { label: 'Strict', value: 'strict' }, + { label: 'Best Fit', value: 'bestfit' }, + { label: 'Fit One, Best Fit', value: 'fitone_bestfit' }, + ], + }, + }), + }, + async run(context) { + const format = context.propsValue.format ?? 'jpeg'; + const baseName = (context.propsValue.path.match(/[^/]+$/) ?? ['thumbnail'])[0]; + const { data, result } = await dropboxCommon.download({ + auth: context.auth.access_token, + path: '/files/get_thumbnail_v2', + arg: { + resource: { '.tag': 'path', path: context.propsValue.path }, + format, + size: context.propsValue.size ?? 'w64h64', + mode: context.propsValue.mode ?? 'strict', + }, + }); + return { + file: await context.files.write({ + fileName: `${baseName.replace(/\.[^.]+$/, '')}.${ + format === 'jpeg' ? 'jpg' : format + }`, + data, + }), + metadata: result, + }; + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-move-batch-status.ts b/packages/pieces/community/dropbox/src/lib/actions/get-move-batch-status.ts new file mode 100644 index 000000000000..abef630011cc --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-move-batch-status.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { relocationStatusOutputSchema } from '../output-schemas'; + +export const dropboxGetMoveBatchStatus = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_move_batch_status', + classification: 'READ', + displayName: 'Get Move Batch Status', + description: 'Check the status of a move batch job', + audience: 'ai', + aiMetadata: { + description: + 'Reads the status of a move batch started by Start Move Batch, returning in_progress or complete with one result per entry. Dropbox reports no overall failure for this job, so check each entry: a batch where every entry failed still reports complete. Call once per check rather than looping. Read-only.', + idempotent: true, + }, + outputSchema: relocationStatusOutputSchema, + props: { + async_job_id: Property.ShortText({ + displayName: 'Async Job ID', + description: 'The job id returned by Start Move Batch.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/move_batch/check_v2', + body: { async_job_id: context.propsValue.async_job_id }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-save-url-status.ts b/packages/pieces/community/dropbox/src/lib/actions/get-save-url-status.ts new file mode 100644 index 000000000000..a78a0e1ca3a3 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-save-url-status.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { saveUrlStatusOutputSchema } from '../output-schemas'; + +export const dropboxGetSaveUrlStatus = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_save_url_status', + classification: 'READ', + displayName: 'Get Save URL Status', + description: 'Check the status of a Save URL job', + audience: 'ai', + aiMetadata: { + description: + 'Reads the status of a Save URL job started by Save URL to Dropbox, returning in_progress, complete with the saved file metadata, or failed. Call it once per check rather than looping; re-run it after a delay while the status is in_progress. Read-only.', + idempotent: true, + }, + outputSchema: saveUrlStatusOutputSchema, + props: { + async_job_id: Property.ShortText({ + displayName: 'Async Job ID', + description: 'The job id returned by Save URL to Dropbox.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/save_url/check_job_status', + body: { async_job_id: context.propsValue.async_job_id }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-thumbnails-batch.ts b/packages/pieces/community/dropbox/src/lib/actions/get-thumbnails-batch.ts new file mode 100644 index 000000000000..2f0b451a6b3a --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/get-thumbnails-batch.ts @@ -0,0 +1,81 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { thumbnailBatchOutputSchema } from '../output-schemas'; + +export const dropboxGetThumbnailsBatch = createAction({ + auth: dropboxAuth, + name: 'get_dropbox_thumbnails_batch', + classification: 'READ', + displayName: 'Get Thumbnails (Batch)', + description: 'Get thumbnails for several files at once', + audience: 'ai', + aiMetadata: { + description: + 'Returns base64-encoded thumbnails for up to 25 Dropbox image files in one call. Use to preview many images at once; use Get File Thumbnail for a single file you want as a downloadable file object. Read-only.', + idempotent: true, + }, + outputSchema: thumbnailBatchOutputSchema, + props: { + paths: Property.Array({ + displayName: 'Paths', + description: + 'The image files to thumbnail, at most 25. Each accepts a path (/folder/photo.jpg), an id (id:abc123) or a revision (rev:a1c10ce0dd78).', + required: true, + }), + format: Property.StaticDropdown({ + displayName: 'Format', + required: false, + defaultValue: 'jpeg', + options: { + options: [ + { label: 'JPEG', value: 'jpeg' }, + { label: 'PNG', value: 'png' }, + { label: 'WebP', value: 'webp' }, + ], + }, + }), + size: Property.StaticDropdown({ + displayName: 'Size', + required: false, + defaultValue: 'w64h64', + options: { + options: [ + { label: '32x32', value: 'w32h32' }, + { label: '64x64', value: 'w64h64' }, + { label: '128x128', value: 'w128h128' }, + { label: '256x256', value: 'w256h256' }, + { label: '480x320', value: 'w480h320' }, + { label: '640x480', value: 'w640h480' }, + { label: '960x640', value: 'w960h640' }, + { label: '1024x768', value: 'w1024h768' }, + { label: '2048x1536', value: 'w2048h1536' }, + ], + }, + }), + }, + async run(context) { + const paths = dropboxCommon.parseStringArray({ + value: context.propsValue.paths, + displayName: 'Paths', + }); + if (paths.length === 0) { + throw new Error('Provide at least one path to thumbnail.'); + } + if (paths.length > 25) { + throw new Error('Dropbox accepts at most 25 paths per thumbnail batch.'); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + host: dropboxCommon.CONTENT_HOST, + path: '/files/get_thumbnail_batch', + body: { + entries: paths.map((path) => ({ + path, + format: context.propsValue.format ?? 'jpeg', + size: context.propsValue.size ?? 'w64h64', + })), + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/list-file-revisions.ts b/packages/pieces/community/dropbox/src/lib/actions/list-file-revisions.ts new file mode 100644 index 000000000000..e56bfc101fd1 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/list-file-revisions.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { listRevisionsOutputSchema } from '../output-schemas'; + +export const dropboxListFileRevisions = createAction({ + auth: dropboxAuth, + name: 'list_dropbox_file_revisions', + classification: 'READ', + displayName: 'List File Revisions', + description: 'List the stored revisions of a file', + audience: 'ai', + aiMetadata: { + description: + 'Lists the stored revisions of a Dropbox file, newest first, each with its rev identifier and modification time. Use to inspect a file history, or to obtain the rev value that Restore File requires. Read-only.', + idempotent: true, + }, + outputSchema: listRevisionsOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file whose revisions to list. Accepts a path (/folder/file.txt) or an id (id:abc123).', + required: true, + }), + mode: Property.StaticDropdown({ + displayName: 'Mode', + description: + 'Path returns revisions of whatever currently sits at the path; ID follows the same file across moves and renames.', + required: false, + defaultValue: 'path', + options: { + options: [ + { label: 'Path', value: 'path' }, + { label: 'ID', value: 'id' }, + ], + }, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum revisions to return, between 1 and 100.', + defaultValue: 10, + required: false, + }), + }, + async run(context) { + const limit = context.propsValue.limit ?? 10; + if (limit < 1 || limit > 100) { + throw new Error('Limit must be between 1 and 100.'); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/list_revisions', + body: { + path: context.propsValue.path, + mode: context.propsValue.mode ?? 'path', + limit, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/lock-file.ts b/packages/pieces/community/dropbox/src/lib/actions/lock-file.ts new file mode 100644 index 000000000000..9ec206a6f5f7 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/lock-file.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { lockResultOutputSchema } from '../output-schemas'; + +export const dropboxLockFile = createAction({ + auth: dropboxAuth, + name: 'lock_dropbox_file', + classification: 'WRITE', + displayName: 'Lock File', + description: 'Temporarily lock a file against edits', + audience: 'ai', + aiMetadata: { + description: + 'Locks a Dropbox file so other members cannot edit it until it is unlocked. Use before rewriting a shared file so a concurrent edit cannot be lost; release it with Unlock File. Idempotent: locking an already-locked file leaves it locked.', + idempotent: true, + }, + outputSchema: lockResultOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file to lock. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.', + required: true, + }), + }, + async run(context) { + const response = await dropboxCommon.rpc<{ entries?: unknown }>({ + auth: context.auth.access_token, + path: '/files/lock_file_batch', + body: { entries: [{ path: context.propsValue.path }] }, + }); + return dropboxCommon.unwrapEntry({ entries: response.entries, action: 'lock the file' }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/move-entry.ts b/packages/pieces/community/dropbox/src/lib/actions/move-entry.ts new file mode 100644 index 000000000000..fa8a0bdaaf40 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/move-entry.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { entryOperationOutputSchema } from '../output-schemas'; + +export const dropboxMoveEntry = createAction({ + auth: dropboxAuth, + name: 'move_dropbox_entry', + classification: 'WRITE', + displayName: 'Move Entry', + description: 'Move or rename a file or folder', + audience: 'ai', + aiMetadata: { + description: + 'Moves a Dropbox file or folder to a new path, which also renames it. Handles both files and folders, so no separate action is needed for each. Use Start Move Batch for many entries at once. Not idempotent: once moved, repeating the call fails because the source path no longer exists.', + idempotent: false, + }, + outputSchema: entryOperationOutputSchema, + props: { + from_path: Property.ShortText({ + displayName: 'From Path', + description: + 'The file or folder to move. Accepts a path (/folder/file.txt) or an id (id:abc123).', + required: true, + }), + to_path: Property.ShortText({ + displayName: 'To Path', + description: 'The destination path, for example /folder2/renamed.txt.', + required: true, + }), + autorename: Property.Checkbox({ + displayName: 'Auto Rename', + description: 'Let Dropbox rename the entry when the destination conflicts.', + defaultValue: false, + required: false, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/move_v2', + body: { + from_path: context.propsValue.from_path, + to_path: context.propsValue.to_path, + autorename: context.propsValue.autorename ?? false, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/move-file.ts b/packages/pieces/community/dropbox/src/lib/actions/move-file.ts index 2709a7fccb99..9ae5dc0c239e 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/move-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/move-file.ts @@ -12,7 +12,7 @@ export const dropboxMoveFile = createAction({ name: 'move_dropbox_file', classification: 'WRITE', description: 'Move a file', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Moves (or renames) the file at the source path to a new destination path within Dropbox; optionally autorenames on conflict. Use to relocate or rename a single file. Not idempotent: after a successful move the source no longer exists, so repeating the call fails or, with autorename, can produce a differently named copy.', idempotent: false }, displayName: 'Move file', outputSchema: fileMetadataOutputSchema, diff --git a/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts index ebeb41b98092..04147debc9cd 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts @@ -12,7 +12,7 @@ export const dropboxMoveFolder = createAction({ name: 'move_dropbox_folder', classification: 'WRITE', description: 'Move a folder', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Moves (or renames) the folder at the source path, including its contents, to a new destination path within Dropbox; optionally autorenames on conflict. Use to relocate or rename an entire directory. Not idempotent: after a successful move the source no longer exists, so repeating the call fails or, with autorename, produces a differently named folder.', idempotent: false }, displayName: 'Move folder', outputSchema: folderMetadataOutputSchema, diff --git a/packages/pieces/community/dropbox/src/lib/actions/restore-file.ts b/packages/pieces/community/dropbox/src/lib/actions/restore-file.ts new file mode 100644 index 000000000000..f7cc00fdd5f2 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/restore-file.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { uploadedFileOutputSchema } from '../output-schemas'; + +export const dropboxRestoreFile = createAction({ + auth: dropboxAuth, + name: 'restore_dropbox_file', + classification: 'WRITE', + displayName: 'Restore File Revision', + description: 'Restore a file to a previous revision', + audience: 'ai', + aiMetadata: { + description: + 'Restores a Dropbox file to a previous revision, obtained from List File Revisions. Use to undo an unwanted change while keeping the history intact. Not idempotent: each call writes a new revision, so repeating it adds further revisions rather than converging.', + idempotent: false, + }, + outputSchema: uploadedFileOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file to restore, as a path (/folder/file.txt). This endpoint does not accept id: or rev: values.', + required: true, + }), + rev: Property.ShortText({ + displayName: 'Revision', + description: + 'The revision to restore the file to. Obtain it from List File Revisions.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/restore', + body: { + path: context.propsValue.path, + rev: context.propsValue.rev, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/save-copy-reference.ts b/packages/pieces/community/dropbox/src/lib/actions/save-copy-reference.ts new file mode 100644 index 000000000000..991317de261f --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/save-copy-reference.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { fileMetadataOutputSchema } from '../output-schemas'; + +export const dropboxSaveCopyReference = createAction({ + auth: dropboxAuth, + name: 'save_dropbox_copy_reference', + classification: 'WRITE', + displayName: 'Save Copy Reference', + description: 'Save a copy reference into this account', + audience: 'ai', + aiMetadata: { + description: + 'Saves a copy reference produced by Create Copy Reference into this Dropbox account at the given path. Use to complete a cross-account copy. Not idempotent: each call writes another copy at the destination path.', + idempotent: false, + }, + outputSchema: fileMetadataOutputSchema, + props: { + copy_reference: Property.ShortText({ + displayName: 'Copy Reference', + description: 'The reference string returned by Create Copy Reference.', + required: true, + }), + path: Property.ShortText({ + displayName: 'Destination Path', + description: + 'Where to save the entry, as a path (/folder/file.txt). This endpoint does not accept id: or ns: values.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/copy_reference/save', + body: { + copy_reference: context.propsValue.copy_reference, + path: context.propsValue.path, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/save-url.ts b/packages/pieces/community/dropbox/src/lib/actions/save-url.ts new file mode 100644 index 000000000000..d6fd4e8614f4 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/save-url.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { saveUrlLaunchOutputSchema } from '../output-schemas'; + +export const dropboxSaveUrl = createAction({ + auth: dropboxAuth, + name: 'save_url_to_dropbox', + classification: 'WRITE', + displayName: 'Save URL to Dropbox', + description: 'Download a URL into Dropbox', + audience: 'ai', + aiMetadata: { + description: + 'Tells Dropbox to fetch a public URL server-side and save it as a file. Small downloads finish immediately and return the file metadata; larger ones return an async job id that Get Save URL Status polls. Not idempotent: each call starts another download and writes another file.', + idempotent: false, + }, + outputSchema: saveUrlLaunchOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Destination Path', + description: + 'Where to save the file, as a path (/folder/file.pdf). This endpoint does not accept id: or ns: values.', + required: true, + }), + url: Property.ShortText({ + displayName: 'URL', + description: 'The publicly reachable URL for Dropbox to download.', + required: true, + }), + }, + async run(context) { + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/save_url', + body: { + path: context.propsValue.path, + url: context.propsValue.url, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/start-copy-batch.ts b/packages/pieces/community/dropbox/src/lib/actions/start-copy-batch.ts new file mode 100644 index 000000000000..48a602f9f996 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/start-copy-batch.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { relocationLaunchOutputSchema } from '../output-schemas'; + +export const dropboxStartCopyBatch = createAction({ + auth: dropboxAuth, + name: 'start_dropbox_copy_batch', + classification: 'WRITE', + displayName: 'Start Copy Batch', + description: 'Copy many files or folders in one job', + audience: 'ai', + aiMetadata: { + description: + 'Starts a job copying up to 1000 Dropbox files or folders in one call, leaving the originals in place. Small batches complete immediately; larger ones return an async job id that Get Copy Batch Status polls. Not idempotent: each call copies again, so repeating it duplicates entries.', + idempotent: false, + }, + outputSchema: relocationLaunchOutputSchema, + props: { + entries: Property.Array({ + displayName: 'Entries', + description: + 'Each entry needs a From Path and a To Path. Both accept a path (/folder/file.txt) or an id (id:abc123). At most 1000 entries.', + required: true, + properties: { + from_path: Property.ShortText({ + displayName: 'From Path', + required: true, + }), + to_path: Property.ShortText({ + displayName: 'To Path', + required: true, + }), + }, + }), + autorename: Property.Checkbox({ + displayName: 'Auto Rename', + description: 'Let Dropbox rename an entry when its destination conflicts.', + defaultValue: false, + required: false, + }), + }, + async run(context) { + const entries = dropboxCommon.parseRelocationEntries({ + value: context.propsValue.entries, + displayName: 'Entries', + }); + if (entries.length === 0) { + throw new Error('Provide at least one entry to copy.'); + } + if (entries.length > 1000) { + throw new Error('Dropbox accepts at most 1000 entries per copy batch.'); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/copy_batch_v2', + body: { + entries: entries.map((entry) => ({ + from_path: entry.from_path, + to_path: entry.to_path, + })), + autorename: context.propsValue.autorename ?? false, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/start-create-folder-batch.ts b/packages/pieces/community/dropbox/src/lib/actions/start-create-folder-batch.ts new file mode 100644 index 000000000000..cc6afb831133 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/start-create-folder-batch.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { createFolderBatchLaunchOutputSchema } from '../output-schemas'; + +export const dropboxStartCreateFolderBatch = createAction({ + auth: dropboxAuth, + name: 'start_dropbox_create_folder_batch', + classification: 'WRITE', + displayName: 'Start Create Folder Batch', + description: 'Create many folders in one job', + audience: 'ai', + aiMetadata: { + description: + 'Starts a job creating up to 10000 Dropbox folders in one call. Small batches complete immediately; larger ones return an async job id that Get Create Folder Batch Status polls. Not idempotent: without autorename a second call conflicts on folders that already exist.', + idempotent: false, + }, + outputSchema: createFolderBatchLaunchOutputSchema, + props: { + paths: Property.Array({ + displayName: 'Paths', + description: + 'The folders to create, at most 10000. Each must be a path such as /reports/2026; this endpoint does not accept id: values.', + required: true, + }), + autorename: Property.Checkbox({ + displayName: 'Auto Rename', + description: 'Let Dropbox rename a folder when its path conflicts.', + defaultValue: false, + required: false, + }), + }, + async run(context) { + const paths = dropboxCommon.parseStringArray({ + value: context.propsValue.paths, + displayName: 'Paths', + }); + if (paths.length === 0) { + throw new Error('Provide at least one folder path to create.'); + } + if (paths.length > 10000) { + throw new Error( + 'Dropbox accepts at most 10000 folders per create folder batch.' + ); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/create_folder_batch', + body: { + paths, + autorename: context.propsValue.autorename ?? false, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/start-delete-batch.ts b/packages/pieces/community/dropbox/src/lib/actions/start-delete-batch.ts new file mode 100644 index 000000000000..def1fa77fd92 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/start-delete-batch.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { deleteBatchLaunchOutputSchema } from '../output-schemas'; + +export const dropboxStartDeleteBatch = createAction({ + auth: dropboxAuth, + name: 'start_dropbox_delete_batch', + classification: 'DESTRUCTIVE', + displayName: 'Start Delete Batch', + description: 'Delete many files or folders in one job', + audience: 'ai', + aiMetadata: { + description: + 'Starts a job deleting up to 1000 Dropbox files or folders in one call. Deleted entries go to trash and remain restorable, they are not erased. Small batches complete immediately; larger ones return an async job id that Get Delete Batch Status polls. Not idempotent: a second call fails on paths already deleted.', + idempotent: false, + }, + outputSchema: deleteBatchLaunchOutputSchema, + props: { + paths: Property.Array({ + displayName: 'Paths', + description: + 'The entries to delete, at most 1000. Each accepts a path (/folder/file.txt) or an id (id:abc123).', + required: true, + }), + }, + async run(context) { + const paths = dropboxCommon.parseStringArray({ + value: context.propsValue.paths, + displayName: 'Paths', + }); + if (paths.length === 0) { + throw new Error('Provide at least one path to delete.'); + } + if (paths.length > 1000) { + throw new Error('Dropbox accepts at most 1000 entries per delete batch.'); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/delete_batch', + body: { entries: paths.map((path) => ({ path })) }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/start-move-batch.ts b/packages/pieces/community/dropbox/src/lib/actions/start-move-batch.ts new file mode 100644 index 000000000000..4d9cc683636a --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/start-move-batch.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { relocationLaunchOutputSchema } from '../output-schemas'; + +export const dropboxStartMoveBatch = createAction({ + auth: dropboxAuth, + name: 'start_dropbox_move_batch', + classification: 'WRITE', + displayName: 'Start Move Batch', + description: 'Move many files or folders in one job', + audience: 'ai', + aiMetadata: { + description: + 'Starts a job moving up to 1000 Dropbox files or folders in one call. Small batches complete immediately; larger ones return an async job id that Get Move Batch Status polls. Not idempotent: once an entry has moved, repeating the call fails because the source path no longer exists.', + idempotent: false, + }, + outputSchema: relocationLaunchOutputSchema, + props: { + entries: Property.Array({ + displayName: 'Entries', + description: + 'Each entry needs a From Path and a To Path. Both accept a path (/folder/file.txt) or an id (id:abc123). At most 1000 entries.', + required: true, + properties: { + from_path: Property.ShortText({ + displayName: 'From Path', + required: true, + }), + to_path: Property.ShortText({ + displayName: 'To Path', + required: true, + }), + }, + }), + autorename: Property.Checkbox({ + displayName: 'Auto Rename', + description: 'Let Dropbox rename an entry when its destination conflicts.', + defaultValue: false, + required: false, + }), + }, + async run(context) { + const entries = dropboxCommon.parseRelocationEntries({ + value: context.propsValue.entries, + displayName: 'Entries', + }); + if (entries.length === 0) { + throw new Error('Provide at least one entry to move.'); + } + if (entries.length > 1000) { + throw new Error('Dropbox accepts at most 1000 entries per move batch.'); + } + return await dropboxCommon.rpc({ + auth: context.auth.access_token, + path: '/files/move_batch_v2', + body: { + entries: entries.map((entry) => ({ + from_path: entry.from_path, + to_path: entry.to_path, + })), + autorename: context.propsValue.autorename ?? false, + }, + }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/actions/unlock-file.ts b/packages/pieces/community/dropbox/src/lib/actions/unlock-file.ts new file mode 100644 index 000000000000..7e355dde3b85 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/actions/unlock-file.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { dropboxAuth } from '../auth'; +import { dropboxCommon } from '../common'; +import { lockResultOutputSchema } from '../output-schemas'; + +export const dropboxUnlockFile = createAction({ + auth: dropboxAuth, + name: 'unlock_dropbox_file', + classification: 'WRITE', + displayName: 'Unlock File', + description: 'Release a lock on a file', + audience: 'ai', + aiMetadata: { + description: + 'Releases a lock previously taken on a Dropbox file so other members can edit it again. Use after finishing the write that Lock File protected. Idempotent: unlocking a file that is not locked leaves it unlocked.', + idempotent: true, + }, + outputSchema: lockResultOutputSchema, + props: { + path: Property.ShortText({ + displayName: 'Path', + description: + 'The file to unlock. Accepts a path (/folder/file.txt) or an id (id:abc123), but not a rev: value.', + required: true, + }), + }, + async run(context) { + const response = await dropboxCommon.rpc<{ entries?: unknown }>({ + auth: context.auth.access_token, + path: '/files/unlock_file_batch', + body: { entries: [{ path: context.propsValue.path }] }, + }); + return dropboxCommon.unwrapEntry({ entries: response.entries, action: 'unlock the file' }); + }, +}); diff --git a/packages/pieces/community/dropbox/src/lib/common.test.ts b/packages/pieces/community/dropbox/src/lib/common.test.ts new file mode 100644 index 000000000000..baa5f432a08c --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/common.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { dropboxCommon } from './common'; + +describe('unwrapEntry', () => { + it('returns the first entry when Dropbox reports success', () => { + const entry = { '.tag': 'success', metadata: { name: 'doc.txt' } }; + expect( + dropboxCommon.unwrapEntry({ entries: [entry], action: 'lock the file' }) + ).toEqual(entry); + }); + + it('throws when the single entry failed, so a per-entry failure is not reported as success', () => { + const entries = [ + { '.tag': 'failure', failure: { '.tag': 'path_lookup' } }, + ]; + expect(() => + dropboxCommon.unwrapEntry({ entries, action: 'lock the file' }) + ).toThrowError(/could not lock the file/); + }); + + it('includes the failure payload in the message', () => { + const entries = [{ '.tag': 'failure', failure: { '.tag': 'not_found' } }]; + expect(() => + dropboxCommon.unwrapEntry({ entries, action: 'unlock the file' }) + ).toThrowError(/not_found/); + }); + + it('throws when Dropbox returns no entries at all', () => { + expect(() => + dropboxCommon.unwrapEntry({ entries: [], action: 'read the file lock' }) + ).toThrowError(/no result entry/); + }); + + it('throws when the entries field is missing rather than returning undefined', () => { + expect(() => + dropboxCommon.unwrapEntry({ entries: undefined, action: 'lock the file' }) + ).toThrowError(/no result entry/); + }); +}); + +describe('previewExtensionFor', () => { + it('uses html when Dropbox returns an HTML preview', () => { + expect(dropboxCommon.previewExtensionFor('text/html')).toBe('html'); + }); + + it('uses pdf for the PDF preview content type', () => { + expect(dropboxCommon.previewExtensionFor('application/pdf')).toBe('pdf'); + }); + + it('falls back to pdf when the content type is absent', () => { + expect(dropboxCommon.previewExtensionFor(undefined)).toBe('pdf'); + }); +}); \ No newline at end of file diff --git a/packages/pieces/community/dropbox/src/lib/common.ts b/packages/pieces/community/dropbox/src/lib/common.ts new file mode 100644 index 000000000000..8619b24eb381 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/common.ts @@ -0,0 +1,237 @@ +import { + AuthenticationType, + httpClient, + HttpMethod, +} from '@activepieces/pieces-common'; + +const API_HOST = 'https://api.dropboxapi.com/2'; +const CONTENT_HOST = 'https://content.dropboxapi.com/2'; + +export const dropboxCommon = { + CONTENT_HOST, + rpc, + download, + unwrapEntry, + previewExtensionFor, + parseStringArray, + parseRelocationEntries, +}; + +function encodeApiArg(arg: unknown): string { + return JSON.stringify(arg).replace( + /[\u007f-￿]/g, + (c) => '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4) + ); +} + +function describeError(error: unknown): string | undefined { + if (!isObject(error)) { + return undefined; + } + const response = error['response']; + if (!isObject(response)) { + return undefined; + } + const body = response['body']; + const summary = + isObject(body) && 'error_summary' in body + ? String(body['error_summary']) + : undefined; + switch (response['status']) { + case 401: + return 'Dropbox rejected the credentials. Reconnect the Dropbox connection.'; + case 403: + return `Dropbox denied this request. The connection may lack the required scope, or the account plan may not include this feature. ${ + summary ?? '' + }`.trim(); + case 409: + return `Dropbox could not complete the request: ${ + summary ?? 'the path or resource is in an unexpected state.' + }`; + case 429: + return 'Dropbox rate limit reached. Retry after a short delay.'; + default: + return summary; + } +} + +async function rpc({ + auth, + path, + body, + host = API_HOST, +}: { + auth: string; + path: string; + body: unknown; + host?: string; +}): Promise { + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${host}${path}`, + ...(body === null + ? {} + : { headers: { 'Content-Type': 'application/json' }, body }), + authentication: { type: AuthenticationType.BEARER_TOKEN, token: auth }, + }); + return response.body; + } catch (error) { + const message = describeError(error); + if (message !== undefined) { + throw new Error(message); + } + throw error; + } +} + +async function download({ + auth, + path, + arg, +}: { + auth: string; + path: string; + arg: unknown; +}): Promise<{ + data: Buffer; + result: TResult | undefined; + contentType: string | undefined; +}> { + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${CONTENT_HOST}${path}`, + headers: { + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': encodeApiArg(arg), + }, + authentication: { type: AuthenticationType.BEARER_TOKEN, token: auth }, + responseType: 'stream', + }); + const header = response.headers?.['dropbox-api-result']; + const contentType = response.headers?.['content-type']; + return { + data: response.body, + result: typeof header === 'string' ? JSON.parse(header) : undefined, + contentType: + typeof contentType === 'string' + ? contentType.split(';')[0].trim() + : undefined, + }; + } catch (error) { + const message = describeError(error); + if (message !== undefined) { + throw new Error(message); + } + throw error; + } +} + +function unwrapEntry({ + entries, + action, +}: { + entries: unknown; + action: string; +}): unknown { + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error(`Dropbox returned no result entry for ${action}.`); + } + const entry: unknown = entries[0]; + if (isObject(entry) && entry['.tag'] === 'failure') { + throw new Error( + `Dropbox could not ${action}: ${JSON.stringify(entry['failure'])}` + ); + } + return entry; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function previewExtensionFor(contentType: string | undefined): string { + return contentType === 'text/html' ? 'html' : 'pdf'; +} + +function toArray({ + value, + displayName, +}: { + value: unknown; + displayName: string; +}): unknown[] { + if (Array.isArray(value)) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return []; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + throw new Error(`${displayName} must be a list.`); + } + } + throw new Error(`${displayName} must be a list.`); +} + +function parseStringArray({ + value, + displayName, +}: { + value: unknown; + displayName: string; +}): string[] { + const items = toArray({ value, displayName }); + const strings = items.filter(isNonEmptyString); + if (strings.length !== items.length) { + throw new Error( + `Every entry in ${displayName} must be a non-empty text value.` + ); + } + return strings; +} + +function parseRelocationEntries({ + value, + displayName, +}: { + value: unknown; + displayName: string; +}): RelocationEntry[] { + const items = toArray({ value, displayName }); + const entries = items.filter(isRelocationEntry); + if (entries.length !== items.length) { + throw new Error( + `Every entry in ${displayName} must have a From Path and a To Path.` + ); + } + return entries.map((entry) => ({ + from_path: entry.from_path, + to_path: entry.to_path, + })); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isRelocationEntry(value: unknown): value is RelocationEntry { + return ( + isObject(value) && + isNonEmptyString(value['from_path']) && + isNonEmptyString(value['to_path']) + ); +} + +type RelocationEntry = { + from_path: string; + to_path: string; +}; diff --git a/packages/pieces/community/dropbox/src/lib/output-schemas.test.ts b/packages/pieces/community/dropbox/src/lib/output-schemas.test.ts new file mode 100644 index 000000000000..d27640859429 --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/output-schemas.test.ts @@ -0,0 +1,86 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; +import { describe, expect, it } from 'vitest'; +import { + createFolderBatchLaunchOutputSchema, + createFolderBatchStatusOutputSchema, + deleteBatchLaunchOutputSchema, + deleteBatchStatusOutputSchema, + entryOperationOutputSchema, + relocationLaunchOutputSchema, + relocationStatusOutputSchema, + saveUrlLaunchOutputSchema, +} from './output-schemas'; + +function collectKeys(fields: OutputSchemaField[] | undefined): string[] { + if (fields === undefined) { + return []; + } + return fields.flatMap((field) => [ + field.key, + ...collectKeys(field.children), + ...collectKeys(field.listItems), + ]); +} + +const allSchemas: Record = { + saveUrlLaunchOutputSchema, + relocationLaunchOutputSchema, + relocationStatusOutputSchema, + deleteBatchLaunchOutputSchema, + deleteBatchStatusOutputSchema, + createFolderBatchLaunchOutputSchema, + createFolderBatchStatusOutputSchema, + entryOperationOutputSchema, +}; + +describe('Dropbox union discriminator paths', () => { + it.each(Object.entries(allSchemas))( + 'escapes the .tag discriminator in %s', + (_name, schema) => { + for (const key of collectKeys(schema.fields)) { + if (!key.includes('tag')) { + continue; + } + expect(key).toContain("['.tag']"); + expect(key).not.toMatch(/(^|\.)\.?tag$/); + } + } + ); +}); + +describe('async job schemas describe both union branches', () => { + it.each([ + ['saveUrlLaunchOutputSchema', saveUrlLaunchOutputSchema], + ['relocationLaunchOutputSchema', relocationLaunchOutputSchema], + ['deleteBatchLaunchOutputSchema', deleteBatchLaunchOutputSchema], + ['createFolderBatchLaunchOutputSchema', createFolderBatchLaunchOutputSchema], + ])( + '%s exposes async_job_id alongside the inline-complete fields', + (_name, schema) => { + const keys = collectKeys(schema.fields); + expect(keys).toContain('async_job_id'); + expect(keys).toContain("['.tag']"); + expect(keys.length).toBeGreaterThan(2); + } + ); +}); + +describe('batch status schemas surface per-entry outcomes', () => { + it.each([ + ['relocationStatusOutputSchema', relocationStatusOutputSchema], + ['deleteBatchStatusOutputSchema', deleteBatchStatusOutputSchema], + ['createFolderBatchStatusOutputSchema', createFolderBatchStatusOutputSchema], + ])('%s exposes a per-entry failure reason', (_name, schema) => { + expect(collectKeys(schema.fields)).toContain("failure['.tag']"); + }); +}); + +describe('entryOperationOutputSchema', () => { + it('labels the result as an entry rather than a file, since folders are accepted', () => { + expect(entryOperationOutputSchema.fields[0].label).toBe('Entry'); + }); + + it('carries the kind discriminator so folder results are distinguishable', () => { + expect(collectKeys(entryOperationOutputSchema.fields)).toContain("['.tag']"); + }); +}); diff --git a/packages/pieces/community/dropbox/src/lib/output-schemas.ts b/packages/pieces/community/dropbox/src/lib/output-schemas.ts index dfb9c5f5a8ba..fc6a58ad9386 100644 --- a/packages/pieces/community/dropbox/src/lib/output-schemas.ts +++ b/packages/pieces/community/dropbox/src/lib/output-schemas.ts @@ -7,6 +7,22 @@ const entryFields: OutputSchema['fields'] = [ { key: 'path_lower', label: 'Path (Lowercase)' }, ]; +const relocationEntryFields: OutputSchema['fields'] = [ + { key: "['.tag']", label: 'Outcome' }, + { key: 'success.name', label: 'Name' }, + { key: 'success.id', label: 'ID' }, + { key: 'success.path_display', label: 'Path' }, + { key: "failure['.tag']", label: 'Failure Reason' }, +]; + +const batchEntryFields: OutputSchema['fields'] = [ + { key: "['.tag']", label: 'Outcome' }, + { key: 'metadata.name', label: 'Name' }, + { key: 'metadata.id', label: 'ID' }, + { key: 'metadata.path_display', label: 'Path' }, + { key: "failure['.tag']", label: 'Failure Reason' }, +]; + const fileFields: OutputSchema['fields'] = [ ...entryFields, { key: 'size', label: 'Size', format: 'filesize' }, @@ -27,6 +43,28 @@ export const folderMetadataOutputSchema: OutputSchema = { fields: [{ key: 'metadata', label: 'Folder', children: entryFields }], }; +export const entryOperationOutputSchema: OutputSchema = { + fields: [ + { + key: 'metadata', + label: 'Entry', + children: [ + { + key: "['.tag']", + label: 'Kind', + description: 'Either file or folder. The fields below are only present for files.', + }, + ...entryFields, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'client_modified', label: 'Client Modified', format: 'datetime' }, + { key: 'server_modified', label: 'Server Modified', format: 'datetime' }, + { key: 'rev', label: 'Revision' }, + { key: 'content_hash', label: 'Content Hash' }, + ], + }, + ], +}; + export const getFileLinkOutputSchema: OutputSchema = { fields: [ { key: 'link', label: 'Temporary Link', format: 'url' }, @@ -69,3 +107,151 @@ export const searchOutputSchema: OutputSchema = { }; export const newFolderTriggerOutputSchema: OutputSchema = { fields: entryFields }; + +export const entryMetadataOutputSchema: OutputSchema = { + fields: [{ key: "['.tag']", label: 'Kind' }, ...fileFields], +}; + +export const listRevisionsOutputSchema: OutputSchema = { + fields: [ + { key: 'is_deleted', label: 'Is Deleted', format: 'boolean' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'entries', label: 'Revisions', labelKey: 'rev', listItems: fileFields }, + ], +}; + +export const copyReferenceOutputSchema: OutputSchema = { + fields: [ + { key: 'copy_reference', label: 'Copy Reference' }, + { key: 'expires', label: 'Expires', format: 'datetime' }, + { key: 'metadata', label: 'Entry', children: fileFields }, + ], +}; + +export const lockResultOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Outcome' }, + { key: "lock.content['.tag']", label: 'Lock State' }, + { + key: 'metadata', + label: 'File', + children: [ + ...fileFields, + { + key: 'file_lock_info', + label: 'Lock Details', + children: [ + { key: 'is_lockholder', label: 'Is Lock Holder', format: 'boolean' }, + { key: 'lockholder_name', label: 'Lock Holder' }, + { key: 'created', label: 'Locked At', format: 'datetime' }, + ], + }, + ], + }, + ], +}; + +export const uploadLinkOutputSchema: OutputSchema = { + fields: [{ key: 'link', label: 'Upload Link', format: 'url' }], +}; + +export const thumbnailBatchOutputSchema: OutputSchema = { + fields: [ + { + key: 'entries', + label: 'Thumbnails', + labelKey: 'metadata.name', + listItems: [ + { key: 'metadata.name', label: 'Name' }, + { key: 'metadata.path_display', label: 'Path' }, + { key: 'thumbnail', label: 'Thumbnail (base64)' }, + ], + }, + ], +}; + +export const zipDownloadOutputSchema: OutputSchema = { + fields: [ + { key: 'file', label: 'Zip Archive', format: 'url' }, + { key: 'metadata', label: 'Folder', children: entryFields }, + ], +}; + +export const filePreviewOutputSchema: OutputSchema = { + fields: [ + { key: 'file', label: 'Preview', format: 'url' }, + { key: 'metadata', label: 'File', children: fileFields }, + ], +}; + +export const fileThumbnailOutputSchema: OutputSchema = { + fields: [ + { key: 'file', label: 'Thumbnail', format: 'url' }, + { + key: 'metadata.file_metadata', + label: 'File', + children: fileFields, + }, + ], +}; + +export const saveUrlLaunchOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Result' }, + { key: 'async_job_id', label: 'Async Job ID' }, + ...fileFields, + ], +}; + +export const saveUrlStatusOutputSchema: OutputSchema = { + fields: [{ key: "['.tag']", label: 'Status' }, ...fileFields], +}; + +export const relocationLaunchOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Result' }, + { key: 'async_job_id', label: 'Async Job ID' }, + { key: 'entries', label: 'Entries', labelKey: "['.tag']", listItems: relocationEntryFields }, + ], +}; + +export const relocationStatusOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Status' }, + { key: 'entries', label: 'Entries', labelKey: "['.tag']", listItems: relocationEntryFields }, + ], +}; + +export const deleteBatchLaunchOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Result' }, + { key: 'async_job_id', label: 'Async Job ID' }, + { key: 'entries', label: 'Entries', labelKey: "['.tag']", listItems: batchEntryFields }, + ], +}; + +export const deleteBatchStatusOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Status' }, + { key: 'entries', label: 'Entries', labelKey: "['.tag']", listItems: batchEntryFields }, + ], +}; + +export const createFolderBatchLaunchOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Result' }, + { key: 'async_job_id', label: 'Async Job ID' }, + { key: 'entries', label: 'Entries', labelKey: "['.tag']", listItems: batchEntryFields }, + ], +}; + +export const createFolderBatchStatusOutputSchema: OutputSchema = { + fields: [ + { key: "['.tag']", label: 'Status' }, + { key: 'entries', label: 'Entries', labelKey: "['.tag']", listItems: batchEntryFields }, + ], +}; + + + + diff --git a/packages/pieces/community/dropbox/vitest.config.ts b/packages/pieces/community/dropbox/vitest.config.ts new file mode 100644 index 000000000000..9d8c6a715d31 --- /dev/null +++ b/packages/pieces/community/dropbox/vitest.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +const repoRoot = path.resolve(__dirname, '../../../..'); + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve( + repoRoot, + 'packages/pieces/framework/src/index.ts' + ), + '@activepieces/pieces-common': path.resolve( + repoRoot, + 'packages/pieces/common/src/index.ts' + ), + }, + }, +}); diff --git a/packages/pieces/community/instagram-business/package.json b/packages/pieces/community/instagram-business/package.json index d7576ef74f05..a8e7abf33d2b 100644 --- a/packages/pieces/community/instagram-business/package.json +++ b/packages/pieces/community/instagram-business/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-instagram-business", - "version": "0.2.7", + "version": "1.0.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/instagram-business/src/i18n/translation.json b/packages/pieces/community/instagram-business/src/i18n/translation.json index bc4ae37179b1..fa1e4165967b 100644 --- a/packages/pieces/community/instagram-business/src/i18n/translation.json +++ b/packages/pieces/community/instagram-business/src/i18n/translation.json @@ -1,14 +1,105 @@ { "Grow your business on Instagram": "Grow your business on Instagram", - "\nTo Obtain the following credentials:\n1. Visit https://developers.facebook.com/\n2. Create an application, Select Other for Usecase.\n3. Select Business as App Type.\n4. Copy App Id and App Secret from Basic Settings.\n": "\nTo Obtain the following credentials:\n1. Visit https://developers.facebook.com/\n2. Create an application, Select Other for Usecase.\n3. Select Business as App Type.\n4. Copy App Id and App Secret from Basic Settings.\n", + "\n**Use the Facebook App ID and App Secret — not the Instagram ones.**\n\n1. Visit https://developers.facebook.com/ and create an app.\n2. Choose **Other** as the use case, then **Business** as the app type. Any other\n combination leaves the app without the Instagram permissions this piece requests.\n3. Add the **Facebook Login for Business** product.\n4. Copy the **App ID** and **App Secret** from **Settings → Basic**.\n\nThe Instagram product page shows a separate *Instagram app ID* and *Instagram app secret*.\n": "\n**Use the Facebook App ID and App Secret — not the Instagram ones.**\n\n1. Visit https://developers.facebook.com/ and create an app.\n2. Choose **Other** as the use case, then **Business** as the app type. Any other\n combination leaves the app without the Instagram permissions this piece requests.\n3. Add the **Facebook Login for Business** product.\n4. Copy the **App ID** and **App Secret** from **Settings → Basic**.\n\nThe Instagram product page shows a separate *Instagram app ID* and *Instagram app secret*.\nThose belong to Instagram Login and will not work here — this piece uses Facebook Login.\n\nYour Instagram account must be a Business or Creator account linked to a Facebook Page.\n", "Upload Photo": "Upload Photo", "Upload Reel": "Upload Reel", + "Publish Story": "Publish Story", + "Publish Carousel": "Publish Carousel", + "Get Profile": "Get Profile", + "Get Account Insights": "Get Account Insights", + "Get Content Publishing Limit": "Get Content Publishing Limit", + "List Media": "List Media", + "Get Media": "Get Media", + "Get Media Insights": "Get Media Insights", + "List Carousel Children": "List Carousel Children", + "List Stories": "List Stories", + "List Tagged Media": "List Tagged Media", + "Delete Media": "Delete Media", + "Enable or Disable Comments": "Enable or Disable Comments", + "List Comments": "List Comments", + "Get Comment": "Get Comment", + "List Comment Replies": "List Comment Replies", + "Create Comment": "Create Comment", + "Reply To Comment": "Reply To Comment", + "Hide or Unhide Comment": "Hide or Unhide Comment", + "Delete Comment": "Delete Comment", + "List Conversations": "List Conversations", + "List Messages": "List Messages", + "Send Message": "Send Message", + "Send Media Message": "Send Media Message", + "Send Private Reply": "Send Private Reply", "Upload a photo to an Instagram Professional Account": "Upload a photo to an Instagram Professional Account", "Upload a reel to an Instagram Professional Account": "Upload a reel to an Instagram Professional Account", + "Publish a photo or video story to an Instagram professional account.": "Publish a photo or video story to an Instagram professional account.", + "Publish a carousel album of up to 10 photos or videos.": "Publish a carousel album of up to 10 photos or videos.", + "Read the profile of the connected Instagram professional account.": "Read the profile of the connected Instagram professional account.", + "Read reach, views and interaction metrics for the account.": "Read reach, views and interaction metrics for the account.", + "Check how many posts the account has published in the last 24 hours.": "Check how many posts the account has published in the last 24 hours.", + "List posts published by the connected Instagram account.": "List posts published by the connected Instagram account.", + "Read a single Instagram post by its id.": "Read a single Instagram post by its id.", + "Read reach, likes and comment metrics for one post.": "Read reach, likes and comment metrics for one post.", + "List the individual items inside a carousel album post.": "List the individual items inside a carousel album post.", + "List the stories currently live on the connected Instagram account.": "List the stories currently live on the connected Instagram account.", + "List posts by other accounts that tagged this account.": "List posts by other accounts that tagged this account.", + "Permanently delete one of your Instagram posts.": "Permanently delete one of your Instagram posts.", + "Turn commenting on or off for one post.": "Turn commenting on or off for one post.", + "List the top-level comments on one Instagram post.": "List the top-level comments on one Instagram post.", + "Read a single comment by its id.": "Read a single comment by its id.", + "List the replies to one comment.": "List the replies to one comment.", + "Post a comment on one of your Instagram posts.": "Post a comment on one of your Instagram posts.", + "Reply to a comment on one of your Instagram posts.": "Reply to a comment on one of your Instagram posts.", + "Hide a comment from public view, or unhide it again.": "Hide a comment from public view, or unhide it again.", + "Permanently delete a comment.": "Permanently delete a comment.", + "List Instagram direct message threads for the account.": "List Instagram direct message threads for the account.", + "Read the messages inside one direct message thread.": "Read the messages inside one direct message thread.", + "Send a direct message to an Instagram user.": "Send a direct message to an Instagram user.", + "Send an image, video or audio file as a direct message.": "Send an image, video or audio file as a direct message.", + "Reply privately by direct message to someone who commented.": "Reply privately by direct message to someone who commented.", "Page": "Page", "Photo": "Photo", "Caption": "Caption", "Video": "Video", + "Photo URL": "Photo URL", + "Video URL": "Video URL", + "Items": "Items", + "Metrics": "Metrics", + "Period": "Period", + "Limit": "Limit", + "Cursor": "Cursor", + "Media ID": "Media ID", + "Carousel Media ID": "Carousel Media ID", + "Comments Enabled": "Comments Enabled", + "Comment ID": "Comment ID", + "Parent Comment ID": "Parent Comment ID", + "Comment": "Comment", + "Reply": "Reply", + "Hidden": "Hidden", + "Conversation ID": "Conversation ID", + "Recipient ID": "Recipient ID", + "Message": "Message", + "Media Type": "Media Type", + "Media URL": "Media URL", "A URL we can access for the photo (JPG only)": "A URL we can access for the photo (JPG only)", - "A URL we can access for the video (Limit: 1GB or 15 minutes)": "A URL we can access for the video (Limit: 1GB or 15 minutes)" + "A URL we can access for the video (Limit: 1GB or 15 minutes)": "A URL we can access for the video (Limit: 1GB or 15 minutes)", + "Public URL of a JPG image. Leave empty when publishing a video story.": "Public URL of a JPG image. Leave empty when publishing a video story.", + "Public URL of a video. Leave empty when publishing a photo story.": "Public URL of a video. Leave empty when publishing a photo story.", + "Between 2 and 10 photos or videos, in the order they should appear.": "Between 2 and 10 photos or videos, in the order they should appear.", + "Metric names, for example reach or profile_views. Defaults to reach.": "Metric names, for example reach or profile_views. Defaults to reach.", + "Maximum number of posts to return (default 25).": "Maximum number of posts to return (default 25).", + "Next Cursor from a previous run, to fetch the following page.": "Next Cursor from a previous run, to fetch the following page.", + "The id of the post, as returned by List Media or a publish action.": "The id of the post, as returned by List Media or a publish action.", + "Metric names. Defaults to reach, likes and comments (reach, replies and shares for stories).": "Metric names. Defaults to reach, likes and comments (reach, replies and shares for stories).", + "The id of a post whose media type is CAROUSEL_ALBUM.": "The id of a post whose media type is CAROUSEL_ALBUM.", + "Maximum number of comments to return (default 25).": "Maximum number of comments to return (default 25).", + "On hides the comment, off makes it visible again.": "On hides the comment, off makes it visible again.", + "From List Conversations.": "From List Conversations.", + "The Instagram-scoped user id of the person to message.": "The Instagram-scoped user id of the person to message.", + "Publicly reachable URL of the file. Instagram fetches it server side and rejects some hosts with an upload error, so prefer a plain static file URL.": "Publicly reachable URL of the file. Instagram fetches it server side and rejects some hosts with an upload error, so prefer a plain static file URL.", + "The comment whose author should receive the direct message.": "The comment whose author should receive the direct message.", + "Day": "Day", + "Week": "Week", + "28 Days": "28 Days", + "Lifetime": "Lifetime", + "Image": "Image", + "Audio": "Audio" } \ No newline at end of file diff --git a/packages/pieces/community/instagram-business/src/index.ts b/packages/pieces/community/instagram-business/src/index.ts index 9ecbf8964f21..6ed54aea01de 100644 --- a/packages/pieces/community/instagram-business/src/index.ts +++ b/packages/pieces/community/instagram-business/src/index.ts @@ -1,17 +1,70 @@ import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; -import { uploadPhoto } from './lib/actions/upload-photo'; -import { uploadReel } from './lib/actions/upload-reel'; +import { getAccountInsights } from './lib/actions/account/get-account-insights'; +import { getContentPublishingLimit } from './lib/actions/account/get-content-publishing-limit'; +import { getProfile } from './lib/actions/account/get-profile'; +import { createComment } from './lib/actions/comments/create-comment'; +import { deleteComment } from './lib/actions/comments/delete-comment'; +import { getComment } from './lib/actions/comments/get-comment'; +import { listCommentReplies } from './lib/actions/comments/list-comment-replies'; +import { listMediaComments } from './lib/actions/comments/list-media-comments'; +import { replyToComment } from './lib/actions/comments/reply-to-comment'; +import { setCommentVisibility } from './lib/actions/comments/set-comment-visibility'; +import { deleteMedia } from './lib/actions/media/delete-media'; +import { getMedia } from './lib/actions/media/get-media'; +import { getMediaInsights } from './lib/actions/media/get-media-insights'; +import { listCarouselChildren } from './lib/actions/media/list-carousel-children'; +import { listMedia } from './lib/actions/media/list-media'; +import { listStories } from './lib/actions/media/list-stories'; +import { listTaggedMedia } from './lib/actions/media/list-tagged-media'; +import { setMediaCommentsEnabled } from './lib/actions/media/set-media-comments-enabled'; +import { listConversations } from './lib/actions/messaging/list-conversations'; +import { listMessages } from './lib/actions/messaging/list-messages'; +import { sendMediaMessage } from './lib/actions/messaging/send-media-message'; +import { sendMessage } from './lib/actions/messaging/send-message'; +import { sendPrivateReply } from './lib/actions/messaging/send-private-reply'; +import { publishCarousel } from './lib/actions/publishing/publish-carousel'; +import { publishStory } from './lib/actions/publishing/publish-story'; +import { uploadPhoto } from './lib/actions/publishing/upload-photo'; +import { uploadReel } from './lib/actions/publishing/upload-reel'; import { instagramCommon } from './lib/common'; export const instagramBusiness = createPiece({ displayName: 'Instagram for Business', description: 'Grow your business on Instagram', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/instagram.png', categories: [PieceCategory.BUSINESS_INTELLIGENCE], authors: ["kishanprmr","MoShizzle","abuaboud"], auth: instagramCommon.authentication, - actions: [uploadPhoto, uploadReel], + actions: [ + uploadPhoto, + uploadReel, + publishStory, + publishCarousel, + getProfile, + getAccountInsights, + getContentPublishingLimit, + listMedia, + getMedia, + getMediaInsights, + listCarouselChildren, + listStories, + listTaggedMedia, + deleteMedia, + setMediaCommentsEnabled, + listMediaComments, + getComment, + listCommentReplies, + createComment, + replyToComment, + setCommentVisibility, + deleteComment, + listConversations, + listMessages, + sendMessage, + sendMediaMessage, + sendPrivateReply, + ], triggers: [], }); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/account/get-account-insights.ts b/packages/pieces/community/instagram-business/src/lib/actions/account/get-account-insights.ts new file mode 100644 index 000000000000..565fbc89a23e --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/account/get-account-insights.ts @@ -0,0 +1,61 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { insightsOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, parseArrayProp } from '../../common'; + +export const getAccountInsights = createAction({ + auth: instagramCommon.authentication, + outputSchema: insightsOutputSchema, + name: 'get_account_insights', + classification: 'READ', + displayName: 'Get Account Insights', + description: 'Read reach, views and interaction metrics for the account.', + audience: 'both', + aiMetadata: { + description: + 'Reads account-level Instagram metrics such as reach, profile views and accounts engaged, over a chosen period. Metrics cover professional accounts only and lag real time by a few hours, so a very recent post may not be reflected yet. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + metrics: Property.Array({ + displayName: 'Metrics', + description: 'Metric names, for example reach or profile_views. Defaults to reach.', + required: false, + }), + period: Property.StaticDropdown({ + displayName: 'Period', + required: false, + defaultValue: 'day', + options: { + options: [ + { label: 'Day', value: 'day' }, + { label: 'Week', value: 'week' }, + { label: '28 Days', value: 'days_28' }, + { label: 'Lifetime', value: 'lifetime' }, + ], + }, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const metrics = parseArrayProp(propsValue.metrics) + .map((metric) => String(metric)) + .filter((metric) => metric.length > 0); + + const response = await instagramCommon.graphRequest<{ data?: unknown[] }>({ + method: HttpMethod.GET, + resourceUri: `/${page.id}/insights`, + accessToken: page.accessToken, + query: { + metric: metrics.length > 0 ? metrics.join(',') : 'reach', + period: propsValue.period ?? 'day', + metric_type: 'total_value', + }, + }); + + const insights = response.data ?? []; + return { insights, count: insights.length }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/account/get-content-publishing-limit.ts b/packages/pieces/community/instagram-business/src/lib/actions/account/get-content-publishing-limit.ts new file mode 100644 index 000000000000..7f04161b766d --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/account/get-content-publishing-limit.ts @@ -0,0 +1,46 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; + +import { getContentPublishingLimitOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const getContentPublishingLimit = createAction({ + auth: instagramCommon.authentication, + outputSchema: getContentPublishingLimitOutputSchema, + name: 'get_content_publishing_limit', + classification: 'READ', + displayName: 'Get Content Publishing Limit', + description: 'Check how many posts the account has published in the last 24 hours.', + audience: 'both', + aiMetadata: { + description: + 'Reads how much of the Instagram publishing quota the account has consumed in the rolling 24 hour window. Instagram allows 100 API-published posts per 24 hours, and a carousel counts as one. Call this before a batch of posts to avoid hitting the cap mid-run. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ + data?: { quota_usage?: number; config?: { quota_total?: number; quota_duration?: number } }[]; + }>({ + method: HttpMethod.GET, + resourceUri: `/${page.id}/content_publishing_limit`, + accessToken: page.accessToken, + query: { fields: 'config,quota_usage' }, + }); + + const entry = response.data?.[0]; + const quotaUsage = entry?.quota_usage ?? 0; + const quotaTotal = entry?.config?.quota_total ?? 100; + + return { + quota_usage: quotaUsage, + quota_total: quotaTotal, + quota_remaining: Math.max(quotaTotal - quotaUsage, 0), + quota_duration_seconds: entry?.config?.quota_duration, + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/account/get-profile.ts b/packages/pieces/community/instagram-business/src/lib/actions/account/get-profile.ts new file mode 100644 index 000000000000..fdcd9307525f --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/account/get-profile.ts @@ -0,0 +1,48 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; + +import { getProfileOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const getProfile = createAction({ + auth: instagramCommon.authentication, + outputSchema: getProfileOutputSchema, + name: 'get_profile', + classification: 'READ', + displayName: 'Get Profile', + description: 'Read the profile of the connected Instagram professional account.', + audience: 'both', + aiMetadata: { + description: + 'Reads the profile of one Instagram professional account: username, name, biography, website, profile picture, follower count, follows count and total media count. Use it to identify the connected account or to check follower numbers. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + return instagramCommon.graphRequest({ + method: HttpMethod.GET, + resourceUri: `/${page.id}`, + accessToken: page.accessToken, + query: { + fields: + 'id,username,name,biography,website,profile_picture_url,followers_count,follows_count,media_count', + }, + }); + }, +}); + +type InstagramProfile = { + id: string; + username?: string; + name?: string; + biography?: string; + website?: string; + profile_picture_url?: string; + followers_count?: number; + follows_count?: number; + media_count?: number; +}; diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/create-comment.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/create-comment.ts new file mode 100644 index 000000000000..074e44c21ae7 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/create-comment.ts @@ -0,0 +1,37 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { commentCreatedOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const createComment = createAction({ + auth: instagramCommon.authentication, + outputSchema: commentCreatedOutputSchema, + name: 'create_comment', + classification: 'WRITE', + displayName: 'Create Comment', + description: 'Post a comment on one of your Instagram posts.', + audience: 'both', + aiMetadata: { + description: + 'Posts a new top-level comment on one of the connected account\u2019s own Instagram posts. To answer an existing comment use Reply To Comment instead, which threads the response. Not idempotent — each call posts another comment.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ displayName: 'Media ID', required: true }), + message: Property.LongText({ displayName: 'Comment', required: true }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const created = await instagramCommon.graphRequest<{ id?: string }>({ + method: HttpMethod.POST, + resourceUri: `/${propsValue.media_id}/comments`, + accessToken: page.accessToken, + body: { message: propsValue.message }, + }); + + return { id: created.id, media_id: propsValue.media_id }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/delete-comment.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/delete-comment.ts new file mode 100644 index 000000000000..313038bc1fd0 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/delete-comment.ts @@ -0,0 +1,35 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { identifierResultOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const deleteComment = createAction({ + auth: instagramCommon.authentication, + outputSchema: identifierResultOutputSchema, + name: 'delete_comment', + classification: 'DESTRUCTIVE', + displayName: 'Delete Comment', + description: 'Permanently delete a comment.', + audience: 'both', + aiMetadata: { + description: + 'Permanently deletes one Instagram comment by its id. This cannot be undone, and only comments on the connected account\u2019s own media can be deleted. To silence a comment reversibly, use Hide Comment instead. Not idempotent — deleting an already deleted comment fails.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + comment_id: Property.ShortText({ displayName: 'Comment ID', required: true }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const result = await instagramCommon.graphRequest<{ success?: boolean }>({ + method: HttpMethod.DELETE, + resourceUri: `/${propsValue.comment_id}`, + accessToken: page.accessToken, + }); + + return { success: result.success ?? true, id: propsValue.comment_id }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/get-comment.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/get-comment.ts new file mode 100644 index 000000000000..f6196ebf684c --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/get-comment.ts @@ -0,0 +1,37 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { getCommentOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, COMMENT_FIELDS } from '../../common'; + +export const getComment = createAction({ + auth: instagramCommon.authentication, + outputSchema: getCommentOutputSchema, + name: 'get_comment', + classification: 'READ', + displayName: 'Get Comment', + description: 'Read a single comment by its id.', + audience: 'both', + aiMetadata: { + description: + 'Reads one Instagram comment by its id, returning its text, author, timestamp, like count, hidden state and the media it belongs to. Use it after a New Comment trigger or List Comments. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + comment_id: Property.ShortText({ + displayName: 'Comment ID', + required: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + return instagramCommon.graphRequest({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.comment_id}`, + accessToken: page.accessToken, + query: { fields: COMMENT_FIELDS }, + }); + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/list-comment-replies.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/list-comment-replies.ts new file mode 100644 index 000000000000..16c82d3b31bf --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/list-comment-replies.ts @@ -0,0 +1,40 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listCommentsOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, COMMENT_FIELDS } from '../../common'; + +export const listCommentReplies = createAction({ + auth: instagramCommon.authentication, + outputSchema: listCommentsOutputSchema, + name: 'list_comment_replies', + classification: 'SEARCH', + displayName: 'List Comment Replies', + description: 'List the replies to one comment.', + audience: 'both', + aiMetadata: { + description: + 'Lists the replies made to one Instagram comment, given the parent comment id. A comment with no replies returns an empty list. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + comment_id: Property.ShortText({ + displayName: 'Parent Comment ID', + required: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ data?: unknown[] }>({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.comment_id}/replies`, + accessToken: page.accessToken, + query: { fields: COMMENT_FIELDS }, + }); + + const comments = response.data ?? []; + return { comments, count: comments.length }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/list-media-comments.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/list-media-comments.ts new file mode 100644 index 000000000000..be25de568d71 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/list-media-comments.ts @@ -0,0 +1,57 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listCommentsOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, GraphPaging, COMMENT_FIELDS } from '../../common'; + +export const listMediaComments = createAction({ + auth: instagramCommon.authentication, + outputSchema: listCommentsOutputSchema, + name: 'list_media_comments', + classification: 'SEARCH', + displayName: 'List Comments', + description: 'List the top-level comments on one Instagram post.', + audience: 'both', + aiMetadata: { + description: + 'Lists the top-level comments on one Instagram post, with author, text, timestamp, like count and whether the comment is hidden. Replies are not included — use List Comment Replies for those. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ + displayName: 'Media ID', + required: true, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of comments to return (default 25).', + required: false, + }), + cursor: instagramCommon.cursor, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ + data?: unknown[]; + paging?: GraphPaging; + }>({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.media_id}/comments`, + accessToken: page.accessToken, + query: { + fields: COMMENT_FIELDS, + limit: propsValue.limit ?? 25, + after: propsValue.cursor, + }, + }); + + const comments = response.data ?? []; + return { + comments, + count: comments.length, + next_cursor: instagramCommon.nextCursor(response.paging), + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/reply-to-comment.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/reply-to-comment.ts new file mode 100644 index 000000000000..a29f129f0b45 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/reply-to-comment.ts @@ -0,0 +1,37 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { commentCreatedOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const replyToComment = createAction({ + auth: instagramCommon.authentication, + outputSchema: commentCreatedOutputSchema, + name: 'reply_to_comment', + classification: 'WRITE', + displayName: 'Reply To Comment', + description: 'Reply to a comment on one of your Instagram posts.', + audience: 'both', + aiMetadata: { + description: + 'Replies to an existing Instagram comment, threading the response underneath it. Instagram supports only one level of threading, so replying to a reply attaches the response to the same parent. Not idempotent — each call posts another reply.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + comment_id: Property.ShortText({ displayName: 'Comment ID', required: true }), + message: Property.LongText({ displayName: 'Reply', required: true }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const created = await instagramCommon.graphRequest<{ id?: string }>({ + method: HttpMethod.POST, + resourceUri: `/${propsValue.comment_id}/replies`, + accessToken: page.accessToken, + body: { message: propsValue.message }, + }); + + return { id: created.id, parent_id: propsValue.comment_id }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/comments/set-comment-visibility.ts b/packages/pieces/community/instagram-business/src/lib/actions/comments/set-comment-visibility.ts new file mode 100644 index 000000000000..c0ce820084d8 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/comments/set-comment-visibility.ts @@ -0,0 +1,42 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { identifierResultOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const setCommentVisibility = createAction({ + auth: instagramCommon.authentication, + outputSchema: identifierResultOutputSchema, + name: 'set_comment_visibility', + classification: 'WRITE', + displayName: 'Hide or Unhide Comment', + description: 'Hide a comment from public view, or unhide it again.', + audience: 'both', + aiMetadata: { + description: + 'Hides an Instagram comment from everyone except its author, or unhides a previously hidden one, controlled by the Hidden toggle. Unlike Delete Comment this is reversible and the comment is not destroyed. Idempotent — setting the same state twice leaves the comment unchanged.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + comment_id: Property.ShortText({ displayName: 'Comment ID', required: true }), + hidden: Property.Checkbox({ + displayName: 'Hidden', + description: 'On hides the comment, off makes it visible again.', + required: true, + defaultValue: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const result = await instagramCommon.graphRequest<{ success?: boolean }>({ + method: HttpMethod.POST, + resourceUri: `/${propsValue.comment_id}`, + accessToken: page.accessToken, + body: { hide: propsValue.hidden }, + }); + + return { success: result.success ?? true, id: propsValue.comment_id }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/delete-media.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/delete-media.ts new file mode 100644 index 000000000000..aaddfb337940 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/delete-media.ts @@ -0,0 +1,35 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { identifierResultOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const deleteMedia = createAction({ + auth: instagramCommon.authentication, + outputSchema: identifierResultOutputSchema, + name: 'delete_media', + classification: 'DESTRUCTIVE', + displayName: 'Delete Media', + description: 'Permanently delete one of your Instagram posts.', + audience: 'both', + aiMetadata: { + description: + 'Permanently deletes one Instagram post, story, reel or whole carousel album belonging to the connected account. Individual items inside a carousel cannot be deleted, only the album as a whole, and the deletion cannot be undone. Not idempotent, since deleting an already deleted post fails.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ displayName: 'Media ID', required: true }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const result = await instagramCommon.graphRequest<{ success?: boolean }>({ + method: HttpMethod.DELETE, + resourceUri: `/${propsValue.media_id}`, + accessToken: page.accessToken, + }); + + return { success: result.success ?? true, id: propsValue.media_id }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/get-media-insights.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/get-media-insights.ts new file mode 100644 index 000000000000..2302f3b80563 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/get-media-insights.ts @@ -0,0 +1,66 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { insightsOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, parseArrayProp } from '../../common'; + +export const getMediaInsights = createAction({ + auth: instagramCommon.authentication, + outputSchema: insightsOutputSchema, + name: 'get_media_insights', + classification: 'READ', + displayName: 'Get Media Insights', + description: 'Read reach, likes and comment metrics for one post.', + audience: 'both', + aiMetadata: { + description: + 'Reads per-post Instagram metrics such as reach, likes, comments, saves and shares for one media id. Available metrics differ by media type, since reels and stories expose different ones from feed images, and an unsupported metric returns an error naming it. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ displayName: 'Media ID', required: true }), + metrics: Property.Array({ + displayName: 'Metrics', + description: 'Metric names. Defaults to reach, likes and comments (reach, replies and shares for stories).', + required: false, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const metrics = parseArrayProp(propsValue.metrics) + .map((metric) => String(metric)) + .filter((metric) => metric.length > 0); + + const metric = + metrics.length > 0 + ? metrics.join(',') + : await defaultMetrics({ mediaId: propsValue.media_id, accessToken: page.accessToken }); + + const response = await instagramCommon.graphRequest<{ data?: unknown[] }>({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.media_id}/insights`, + accessToken: page.accessToken, + query: { metric }, + }); + + const insights = response.data ?? []; + return { insights, count: insights.length }; + }, +}); + +async function defaultMetrics({ + mediaId, + accessToken, +}: { + mediaId: string; + accessToken: string; +}): Promise { + const media = await instagramCommon.graphRequest<{ media_product_type?: string }>({ + method: HttpMethod.GET, + resourceUri: `/${mediaId}`, + accessToken, + query: { fields: 'media_product_type' }, + }); + return media.media_product_type === 'STORY' ? 'reach,replies,shares' : 'reach,likes,comments'; +} diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/get-media.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/get-media.ts new file mode 100644 index 000000000000..44be27244899 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/get-media.ts @@ -0,0 +1,38 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { getMediaOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, MEDIA_FIELDS } from '../../common'; + +export const getMedia = createAction({ + auth: instagramCommon.authentication, + outputSchema: getMediaOutputSchema, + name: 'get_media', + classification: 'READ', + displayName: 'Get Media', + description: 'Read a single Instagram post by its id.', + audience: 'both', + aiMetadata: { + description: + 'Reads one Instagram post, reel or album by its media id, returning caption, permalink, media type, media URL, timestamp and engagement counts. Use it after List Media, or when a trigger or earlier step supplied a media id. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ + displayName: 'Media ID', + description: 'The id of the post, as returned by List Media or a publish action.', + required: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + return instagramCommon.graphRequest({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.media_id}`, + accessToken: page.accessToken, + query: { fields: MEDIA_FIELDS }, + }); + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/list-carousel-children.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/list-carousel-children.ts new file mode 100644 index 000000000000..9d2087bd31f6 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/list-carousel-children.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listCarouselChildrenOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const listCarouselChildren = createAction({ + auth: instagramCommon.authentication, + outputSchema: listCarouselChildrenOutputSchema, + name: 'list_carousel_children', + classification: 'SEARCH', + displayName: 'List Carousel Children', + description: 'List the individual items inside a carousel album post.', + audience: 'both', + aiMetadata: { + description: + 'Lists the individual photos and videos inside one Instagram carousel album, given the album media id. Only works on media whose media_type is CAROUSEL_ALBUM; other posts return an empty list. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ + displayName: 'Carousel Media ID', + description: 'The id of a post whose media type is CAROUSEL_ALBUM.', + required: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ data?: unknown[] }>({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.media_id}/children`, + accessToken: page.accessToken, + query: { fields: 'id,media_type,media_url,permalink,timestamp,thumbnail_url' }, + }); + + const children = response.data ?? []; + return { children, count: children.length }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/list-media.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/list-media.ts new file mode 100644 index 000000000000..8530c67be103 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/list-media.ts @@ -0,0 +1,53 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listMediaOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, GraphPaging, MEDIA_FIELDS } from '../../common'; + +export const listMedia = createAction({ + auth: instagramCommon.authentication, + outputSchema: listMediaOutputSchema, + name: 'list_media', + classification: 'SEARCH', + displayName: 'List Media', + description: 'List posts published by the connected Instagram account.', + audience: 'both', + aiMetadata: { + description: + 'Lists the media (posts, reels and albums) published by the connected Instagram professional account, newest first, with caption, permalink, media type and engagement counts. Use it to find a recent post or its id before reading or commenting on it. Stories are not included — use List Stories for those. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of posts to return (default 25).', + required: false, + }), + cursor: instagramCommon.cursor, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ + data?: unknown[]; + paging?: GraphPaging; + }>({ + method: HttpMethod.GET, + resourceUri: `/${page.id}/media`, + accessToken: page.accessToken, + query: { + fields: MEDIA_FIELDS, + limit: propsValue.limit ?? 25, + after: propsValue.cursor, + }, + }); + + const media = response.data ?? []; + return { + media, + count: media.length, + next_cursor: instagramCommon.nextCursor(response.paging), + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/list-stories.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/list-stories.ts new file mode 100644 index 000000000000..61f82acea425 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/list-stories.ts @@ -0,0 +1,36 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; + +import { listStoriesOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const listStories = createAction({ + auth: instagramCommon.authentication, + outputSchema: listStoriesOutputSchema, + name: 'list_stories', + classification: 'SEARCH', + displayName: 'List Stories', + description: 'List the stories currently live on the connected Instagram account.', + audience: 'both', + aiMetadata: { + description: + 'Lists the stories that are currently live on the connected Instagram professional account. Stories expire after 24 hours and disappear from this list once they do, so an empty result means nothing is live rather than that something failed. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ data?: unknown[] }>({ + method: HttpMethod.GET, + resourceUri: `/${page.id}/stories`, + accessToken: page.accessToken, + query: { fields: 'id,media_type,media_url,permalink,timestamp,thumbnail_url' }, + }); + + const stories = response.data ?? []; + return { stories, count: stories.length }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/list-tagged-media.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/list-tagged-media.ts new file mode 100644 index 000000000000..f5654ec3b8c9 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/list-tagged-media.ts @@ -0,0 +1,49 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listMediaOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, GraphPaging, MEDIA_FIELDS } from '../../common'; + +export const listTaggedMedia = createAction({ + auth: instagramCommon.authentication, + outputSchema: listMediaOutputSchema, + name: 'list_tagged_media', + classification: 'SEARCH', + displayName: 'List Tagged Media', + description: 'List posts by other accounts that tagged this account.', + audience: 'both', + aiMetadata: { + description: + 'Lists posts published by other Instagram accounts in which the connected account was tagged. These belong to other people, so they cannot be edited or deleted, and the list is useful for monitoring brand mentions. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + limit: Property.Number({ displayName: 'Limit', required: false }), + cursor: instagramCommon.cursor, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ + data?: unknown[]; + paging?: GraphPaging; + }>({ + method: HttpMethod.GET, + resourceUri: `/${page.id}/tags`, + accessToken: page.accessToken, + query: { + fields: MEDIA_FIELDS, + limit: propsValue.limit ?? 25, + after: propsValue.cursor, + }, + }); + + const media = response.data ?? []; + return { + media, + count: media.length, + next_cursor: instagramCommon.nextCursor(response.paging), + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/media/set-media-comments-enabled.ts b/packages/pieces/community/instagram-business/src/lib/actions/media/set-media-comments-enabled.ts new file mode 100644 index 000000000000..ca8e681a1041 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/media/set-media-comments-enabled.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { identifierResultOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const setMediaCommentsEnabled = createAction({ + auth: instagramCommon.authentication, + outputSchema: identifierResultOutputSchema, + name: 'set_media_comments_enabled', + classification: 'WRITE', + displayName: 'Enable or Disable Comments', + description: 'Turn commenting on or off for one post.', + audience: 'both', + aiMetadata: { + description: + 'Turns commenting on or off for one Instagram post belonging to the connected account. Disabling hides the existing comments from public view and blocks new ones, and re-enabling restores them. Idempotent, since setting the same state twice leaves the post unchanged.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + media_id: Property.ShortText({ displayName: 'Media ID', required: true }), + enabled: Property.Checkbox({ + displayName: 'Comments Enabled', + required: true, + defaultValue: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const result = await instagramCommon.graphRequest<{ success?: boolean }>({ + method: HttpMethod.POST, + resourceUri: `/${propsValue.media_id}`, + accessToken: page.accessToken, + body: { comment_enabled: propsValue.enabled }, + }); + + return { success: result.success ?? true, id: propsValue.media_id }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/messaging/list-conversations.ts b/packages/pieces/community/instagram-business/src/lib/actions/messaging/list-conversations.ts new file mode 100644 index 000000000000..cb890769f0cf --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/messaging/list-conversations.ts @@ -0,0 +1,51 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listConversationsOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown, GraphPaging } from '../../common'; + +export const listConversations = createAction({ + auth: instagramCommon.authentication, + outputSchema: listConversationsOutputSchema, + name: 'list_conversations', + classification: 'SEARCH', + displayName: 'List Conversations', + description: 'List Instagram direct message threads for the account.', + audience: 'both', + aiMetadata: { + description: + 'Lists the Instagram direct message threads belonging to the connected professional account, newest activity first, with the participants and the time of the last message. Use it to find a conversation id before reading or replying to a thread. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + limit: Property.Number({ displayName: 'Limit', required: false }), + cursor: instagramCommon.cursor, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const pageId = instagramCommon.requirePageId(page); + + const response = await instagramCommon.graphRequest<{ + data?: unknown[]; + paging?: GraphPaging; + }>({ + method: HttpMethod.GET, + resourceUri: `/${pageId}/conversations`, + accessToken: page.accessToken, + query: { + platform: 'instagram', + fields: 'id,updated_time,unread_count,message_count,participants', + limit: propsValue.limit ?? 25, + after: propsValue.cursor, + }, + }); + + const conversations = response.data ?? []; + return { + conversations, + count: conversations.length, + next_cursor: instagramCommon.nextCursor(response.paging), + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/messaging/list-messages.ts b/packages/pieces/community/instagram-business/src/lib/actions/messaging/list-messages.ts new file mode 100644 index 000000000000..a977e28bdb3d --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/messaging/list-messages.ts @@ -0,0 +1,45 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { listMessagesOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const listMessages = createAction({ + auth: instagramCommon.authentication, + outputSchema: listMessagesOutputSchema, + name: 'list_messages', + classification: 'SEARCH', + displayName: 'List Messages', + description: 'Read the messages inside one direct message thread.', + audience: 'both', + aiMetadata: { + description: + 'Reads the messages inside one Instagram direct message thread, given a conversation id from List Conversations, with sender, recipient, text and timestamp for each. Instagram returns only the most recent messages of a thread rather than its full history. Read-only and idempotent.', + idempotent: true, + }, + props: { + page: instagramCommon.page, + conversation_id: Property.ShortText({ + displayName: 'Conversation ID', + description: 'From List Conversations.', + required: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + + const response = await instagramCommon.graphRequest<{ + messages?: { data?: unknown[] }; + }>({ + method: HttpMethod.GET, + resourceUri: `/${propsValue.conversation_id}`, + accessToken: page.accessToken, + query: { + fields: 'messages{id,created_time,from,to,message}', + }, + }); + + const messages = response.messages?.data ?? []; + return { messages, count: messages.length }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-media-message.ts b/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-media-message.ts new file mode 100644 index 000000000000..877766cb4010 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-media-message.ts @@ -0,0 +1,71 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { sendMessageOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const sendMediaMessage = createAction({ + auth: instagramCommon.authentication, + outputSchema: sendMessageOutputSchema, + name: 'send_media_message', + classification: 'WRITE', + displayName: 'Send Media Message', + description: 'Send an image, video or audio file as a direct message.', + audience: 'both', + aiMetadata: { + description: + 'Sends an image, video or audio file as an Instagram direct message, given a publicly reachable URL and the recipient Instagram-scoped user id. The same 24 hour reply window applies as for a text message. Not idempotent, since each call sends another message.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + recipient_id: Property.ShortText({ + displayName: 'Recipient ID', + required: true, + }), + media_type: Property.StaticDropdown({ + displayName: 'Media Type', + required: true, + defaultValue: 'image', + options: { + options: [ + { label: 'Image', value: 'image' }, + { label: 'Video', value: 'video' }, + { label: 'Audio', value: 'audio' }, + ], + }, + }), + url: Property.ShortText({ + displayName: 'Media URL', + description: 'Publicly reachable URL of the file. Instagram fetches it server side and rejects some hosts with an upload error, so prefer a plain static file URL.', + required: true, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const pageId = instagramCommon.requirePageId(page); + + const result = await instagramCommon.graphRequest<{ + recipient_id?: string; + message_id?: string; + }>({ + method: HttpMethod.POST, + resourceUri: `/${pageId}/messages`, + accessToken: page.accessToken, + body: { + recipient: { id: propsValue.recipient_id }, + message: { + attachment: { + type: propsValue.media_type, + payload: { url: propsValue.url }, + }, + }, + }, + }); + + return { + message_id: result.message_id, + recipient_id: result.recipient_id ?? propsValue.recipient_id, + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-message.ts b/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-message.ts new file mode 100644 index 000000000000..a79eba461b4e --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-message.ts @@ -0,0 +1,51 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { sendMessageOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const sendMessage = createAction({ + auth: instagramCommon.authentication, + outputSchema: sendMessageOutputSchema, + name: 'send_message', + classification: 'WRITE', + displayName: 'Send Message', + description: 'Send a direct message to an Instagram user.', + audience: 'both', + aiMetadata: { + description: + 'Sends a text direct message to one Instagram user, addressed by their Instagram-scoped user id as supplied by a New Message trigger or List Conversations. Instagram only allows a reply within 24 hours of that user last messaging the account, so an older thread is rejected. Not idempotent, since each call sends another message.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + recipient_id: Property.ShortText({ + displayName: 'Recipient ID', + description: 'The Instagram-scoped user id of the person to message.', + required: true, + }), + text: Property.LongText({ displayName: 'Message', required: true }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const pageId = instagramCommon.requirePageId(page); + + const result = await instagramCommon.graphRequest<{ + recipient_id?: string; + message_id?: string; + }>({ + method: HttpMethod.POST, + resourceUri: `/${pageId}/messages`, + accessToken: page.accessToken, + body: { + recipient: { id: propsValue.recipient_id }, + message: { text: propsValue.text }, + }, + }); + + return { + message_id: result.message_id, + recipient_id: result.recipient_id ?? propsValue.recipient_id, + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-private-reply.ts b/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-private-reply.ts new file mode 100644 index 000000000000..0a9641ac79b1 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/messaging/send-private-reply.ts @@ -0,0 +1,51 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { sendMessageOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const sendPrivateReply = createAction({ + auth: instagramCommon.authentication, + outputSchema: sendMessageOutputSchema, + name: 'send_private_reply', + classification: 'WRITE', + displayName: 'Send Private Reply', + description: 'Reply privately by direct message to someone who commented.', + audience: 'both', + aiMetadata: { + description: + 'Sends a direct message to the author of a comment, given the comment id rather than a user id, which is how Instagram supports the comment-to-DM pattern. Allowed once per comment and only within 7 days of it, and unlike Send Message it does not need an existing conversation. Not idempotent, and a second private reply to the same comment is rejected.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + comment_id: Property.ShortText({ + displayName: 'Comment ID', + description: 'The comment whose author should receive the direct message.', + required: true, + }), + text: Property.LongText({ displayName: 'Message', required: true }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const pageId = instagramCommon.requirePageId(page); + + const result = await instagramCommon.graphRequest<{ + recipient_id?: string; + message_id?: string; + }>({ + method: HttpMethod.POST, + resourceUri: `/${pageId}/messages`, + accessToken: page.accessToken, + body: { + recipient: { comment_id: propsValue.comment_id }, + message: { text: propsValue.text }, + }, + }); + + return { + message_id: result.message_id, + recipient_id: result.recipient_id, + }; + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/publishing/publish-carousel.ts b/packages/pieces/community/instagram-business/src/lib/actions/publishing/publish-carousel.ts new file mode 100644 index 000000000000..2b2dabb0532a --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/publishing/publish-carousel.ts @@ -0,0 +1,107 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { publishedMediaOutputSchema } from '../../output-schemas'; +import { + instagramCommon, + FacebookPageDropdown, + parseArrayProp, + CONTAINER_TIMEOUT_MS, + CAROUSEL_ALBUM_TIMEOUT_MS, +} from '../../common'; + +export const publishCarousel = createAction({ + auth: instagramCommon.authentication, + outputSchema: publishedMediaOutputSchema, + name: 'publish_carousel', + classification: 'WRITE', + displayName: 'Publish Carousel', + description: 'Publish a carousel album of up to 10 photos or videos.', + audience: 'both', + aiMetadata: { + description: + 'Publishes a single Instagram carousel album containing between 2 and 10 photos or videos, given their public URLs, with an optional caption. Every item is uploaded first and processed in parallel, with up to five minutes for the items and two more for the album. A carousel counts as one post against the 100-per-24-hours publishing quota. Not idempotent — each call publishes a new album.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + items: Property.Array({ + displayName: 'Items', + description: 'Between 2 and 10 photos or videos, in the order they should appear.', + required: true, + properties: { + url: Property.ShortText({ + displayName: 'Media URL', + description: 'Public URL of the photo (JPG) or video.', + required: true, + }), + is_video: Property.Checkbox({ + displayName: 'Is Video', + description: 'Turn on when this item is a video rather than a photo.', + required: false, + defaultValue: false, + }), + }, + }), + caption: instagramCommon.caption, + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const items = parseArrayProp(propsValue.items); + + if (items.length < 2 || items.length > 10) { + throw new Error( + `A carousel needs between 2 and 10 items, but ${items.length} were supplied.`, + ); + } + + const parsedItems = items.map(parseCarouselItem); + const deadline = Date.now() + CONTAINER_TIMEOUT_MS; + + const childIds: string[] = []; + for (const parsed of parsedItems) { + childIds.push( + await instagramCommon.createContainer({ + page, + body: { + ...(parsed.isVideo ? { video_url: parsed.url } : { image_url: parsed.url }), + ...(parsed.isVideo ? { media_type: 'VIDEO' } : {}), + is_carousel_item: true, + }, + }), + ); + } + + await Promise.all( + childIds.map((containerId) => + instagramCommon.waitForContainer({ containerId, page, deadline }), + ), + ); + + const albumId = await instagramCommon.createContainer({ + page, + body: { + media_type: 'CAROUSEL', + children: childIds.join(','), + caption: propsValue.caption, + }, + }); + await instagramCommon.waitForContainer({ + containerId: albumId, + page, + deadline: Date.now() + CAROUSEL_ALBUM_TIMEOUT_MS, + }); + return instagramCommon.publishContainer({ containerId: albumId, page }); + }, +}); + +function parseCarouselItem(item: unknown): { url: string; isVideo: boolean } { + if (typeof item !== 'object' || item === null) { + throw new Error('Each carousel item must supply a Media URL.'); + } + const record: Record = { ...item }; + const url = record['url']; + if (typeof url !== 'string' || url.trim().length === 0) { + throw new Error('Each carousel item must supply a Media URL.'); + } + return { url, isVideo: record['is_video'] === true }; +} diff --git a/packages/pieces/community/instagram-business/src/lib/actions/publishing/publish-story.ts b/packages/pieces/community/instagram-business/src/lib/actions/publishing/publish-story.ts new file mode 100644 index 000000000000..76594700467c --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/actions/publishing/publish-story.ts @@ -0,0 +1,50 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { publishedMediaOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; + +export const publishStory = createAction({ + auth: instagramCommon.authentication, + outputSchema: publishedMediaOutputSchema, + name: 'publish_story', + classification: 'WRITE', + displayName: 'Publish Story', + description: 'Publish a photo or video story to an Instagram professional account.', + audience: 'both', + aiMetadata: { + description: + 'Publishes one photo or video as an Instagram story, which expires after 24 hours. Supply exactly one of Photo URL or Video URL, and the media must be reachable at a public URL. Use Publish Photo or Publish Reel instead for permanent feed posts. Not idempotent — each call publishes a new story.', + idempotent: false, + }, + props: { + page: instagramCommon.page, + photo: Property.ShortText({ + displayName: 'Photo URL', + description: 'Public URL of a JPG image. Leave empty when publishing a video story.', + required: false, + }), + video: Property.ShortText({ + displayName: 'Video URL', + description: 'Public URL of a video. Leave empty when publishing a photo story.', + required: false, + }), + }, + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + const { photo, video } = propsValue; + + if (!photo && !video) { + throw new Error('Provide either a Photo URL or a Video URL.'); + } + if (photo && video) { + throw new Error('Provide only one of Photo URL or Video URL, not both.'); + } + + return instagramCommon.publishMedia({ + page, + body: photo + ? { image_url: photo, media_type: 'STORIES' } + : { video_url: video, media_type: 'STORIES' }, + }); + }, +}); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/upload-photo.ts b/packages/pieces/community/instagram-business/src/lib/actions/publishing/upload-photo.ts similarity index 70% rename from packages/pieces/community/instagram-business/src/lib/actions/upload-photo.ts rename to packages/pieces/community/instagram-business/src/lib/actions/publishing/upload-photo.ts index 0bd73c84a380..98188bad3cbe 100644 --- a/packages/pieces/community/instagram-business/src/lib/actions/upload-photo.ts +++ b/packages/pieces/community/instagram-business/src/lib/actions/publishing/upload-photo.ts @@ -1,9 +1,11 @@ import { createAction } from '@activepieces/pieces-framework'; -import { instagramCommon, FacebookPageDropdown } from '../common'; +import { publishedMediaOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; export const uploadPhoto = createAction({ auth: instagramCommon.authentication, + outputSchema: publishedMediaOutputSchema, name: 'upload_photo', classification: 'WRITE', displayName: 'Upload Photo', @@ -16,12 +18,11 @@ export const uploadPhoto = createAction({ caption: instagramCommon.caption, }, async run({ propsValue }) { - const page: FacebookPageDropdown = propsValue.page!; - const result = await instagramCommon.createPhotoPost( + const page: FacebookPageDropdown = propsValue.page; + return instagramCommon.createPhotoPost({ page, - propsValue.caption, - propsValue.photo - ); - return result; + caption: propsValue.caption, + photo: propsValue.photo, + }); }, }); diff --git a/packages/pieces/community/instagram-business/src/lib/actions/upload-reel.ts b/packages/pieces/community/instagram-business/src/lib/actions/publishing/upload-reel.ts similarity index 69% rename from packages/pieces/community/instagram-business/src/lib/actions/upload-reel.ts rename to packages/pieces/community/instagram-business/src/lib/actions/publishing/upload-reel.ts index ecae76f42624..4b96bd49ae7e 100644 --- a/packages/pieces/community/instagram-business/src/lib/actions/upload-reel.ts +++ b/packages/pieces/community/instagram-business/src/lib/actions/publishing/upload-reel.ts @@ -1,9 +1,11 @@ import { createAction } from '@activepieces/pieces-framework'; -import { instagramCommon, FacebookPageDropdown } from '../common'; +import { publishedMediaOutputSchema } from '../../output-schemas'; +import { instagramCommon, FacebookPageDropdown } from '../../common'; export const uploadReel = createAction({ auth: instagramCommon.authentication, + outputSchema: publishedMediaOutputSchema, name: 'upload_reel', classification: 'WRITE', displayName: 'Upload Reel', @@ -15,13 +17,12 @@ export const uploadReel = createAction({ video: instagramCommon.video, caption: instagramCommon.caption, }, - async run(context) { - const page: FacebookPageDropdown = context.propsValue.page!; - const result = await instagramCommon.createVideoPost( + async run({ propsValue }) { + const page: FacebookPageDropdown = propsValue.page; + return instagramCommon.createVideoPost({ page, - context.propsValue.caption, - context.propsValue.video - ); - return result; + caption: propsValue.caption, + video: propsValue.video, + }); }, }); diff --git a/packages/pieces/community/instagram-business/src/lib/common/index.ts b/packages/pieces/community/instagram-business/src/lib/common/index.ts index 1b98f8ebf67d..b49fce87b05e 100644 --- a/packages/pieces/community/instagram-business/src/lib/common/index.ts +++ b/packages/pieces/community/instagram-business/src/lib/common/index.ts @@ -4,32 +4,221 @@ import { getAccessTokenOrThrow, } from '@activepieces/pieces-common'; import { - OAuth2PropertyValue, PieceAuth, Property, } from '@activepieces/pieces-framework'; const markdown = ` -To Obtain the following credentials: -1. Visit https://developers.facebook.com/ -2. Create an application, Select Other for Usecase. -3. Select Business as App Type. -4. Copy App Id and App Secret from Basic Settings. +**Use the Facebook App ID and App Secret — not the Instagram ones.** + +1. Visit https://developers.facebook.com/ and create an app. +2. Choose **Other** as the use case, then **Business** as the app type. Any other + combination leaves the app without the Instagram permissions this piece requests. +3. Add the **Facebook Login for Business** product. +4. Copy the **App ID** and **App Secret** from **Settings → Basic**. + +The Instagram product page shows a separate *Instagram app ID* and *Instagram app secret*. +Those belong to Instagram Login and will not work here — this piece uses Facebook Login. + +Your Instagram account must be a Business or Creator account linked to a Facebook Page. `; -const instagramBusinessAuth = PieceAuth.OAuth2({ +const instagramBusinessAuth = PieceAuth.OAuth2({ description: markdown, authUrl: 'https://graph.facebook.com/oauth/authorize', tokenUrl: 'https://graph.facebook.com/oauth/access_token', required: true, - scope: ['instagram_basic', 'instagram_content_publish', 'business_management', 'pages_show_list'], -}) -export const instagramCommon = { - baseUrl: 'https://graph.facebook.com/v17.0', + scope: [ + 'instagram_basic', + 'instagram_content_publish', + 'instagram_manage_comments', + 'instagram_manage_insights', + 'instagram_manage_contents', + 'instagram_manage_messages', + 'business_management', + 'pages_show_list', + 'pages_read_engagement', + 'pages_manage_metadata', + 'pages_messaging', + ], +}); + +async function graphRequest({ + method, + resourceUri, + accessToken, + query, + body, +}: GraphRequestParams): Promise { + const queryParams: Record = {}; + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + queryParams[key] = String(value); + } + } + } + + const response = await httpClient.sendRequest({ + method, + url: `${INSTAGRAM_API_BASE}${resourceUri}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + queryParams, + body, + }); + + return response.body; +} + +async function getPages(accessToken: string): Promise { + const response = await graphRequest<{ data?: FacebookPage[] }>({ + method: HttpMethod.GET, + resourceUri: '/me/accounts', + accessToken, + query: { fields: 'id,name,access_token,instagram_business_account' }, + }); + + return response.data ?? []; +} + +async function createContainer({ + page, + body, +}: CreateContainerParams): Promise { + const container = await graphRequest<{ id?: string }>({ + method: HttpMethod.POST, + resourceUri: `/${page.id}/media`, + accessToken: page.accessToken, + body, + }); + + if (!container.id) { + throw new Error( + 'Instagram did not return a media container id. The media URL may not be publicly reachable.', + ); + } + + return container.id; +} + +async function waitForContainer({ + containerId, + page, + deadline = Date.now() + CONTAINER_TIMEOUT_MS, +}: WaitForContainerParams): Promise { + for (;;) { + const container = await graphRequest<{ + status_code?: string; + status?: string; + }>({ + method: HttpMethod.GET, + resourceUri: `/${containerId}`, + accessToken: page.accessToken, + query: { fields: 'status_code,status' }, + }); + + const statusCode = container.status_code; + if (statusCode === 'FINISHED') { + return; + } + if (statusCode === 'ERROR' || statusCode === 'EXPIRED') { + throw new Error( + `Instagram could not process the media (status ${statusCode}): ${ + container.status ?? 'no further detail was returned' + }`, + ); + } + if (Date.now() + CONTAINER_POLL_INTERVAL_MS >= deadline) { + throw new Error( + `Instagram did not finish processing the media in time (status ${ + statusCode ?? 'unknown' + }). The post was not published.`, + ); + } + + await wait(CONTAINER_POLL_INTERVAL_MS); + } +} + +async function publishContainer({ + containerId, + page, +}: PublishContainerParams): Promise { + const published = await graphRequest<{ id?: string }>({ + method: HttpMethod.POST, + resourceUri: `/${page.id}/media_publish`, + accessToken: page.accessToken, + body: { creation_id: containerId }, + }); + + if (!published.id) { + throw new Error('Instagram did not return a media id for the published post.'); + } + + return { id: published.id }; +} + +async function publishMedia({ + page, + body, +}: PublishMediaParams): Promise { + const containerId = await createContainer({ page, body }); + await waitForContainer({ containerId, page }); + return publishContainer({ containerId, page }); +} + +async function createPhotoPost({ + page, + caption, + photo, +}: CreatePhotoPostParams): Promise { + return publishMedia({ page, body: { image_url: photo, caption } }); +} + +async function createVideoPost({ + page, + caption, + video, +}: CreateVideoPostParams): Promise { + return publishMedia({ + page, + body: { video_url: video, caption, media_type: 'REELS' }, + }); +} + +function requirePageId(page: FacebookPageDropdown): string { + if (!page.pageId) { + throw new Error( + 'This step needs the Facebook Page id. Re-open the step and select the Page again to refresh it.', + ); + } + return page.pageId; +} + +function nextCursor(paging: GraphPaging | undefined): string | undefined { + return paging?.next ? paging.cursors?.after : undefined; +} +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const instagramCommon = { authentication: instagramBusinessAuth, + graphRequest, + getPages, + createContainer, + waitForContainer, + publishContainer, + publishMedia, + requirePageId, + nextCursor, + createPhotoPost, + createVideoPost, - page: Property.Dropdown({ + page: Property.Dropdown({ auth: instagramBusinessAuth, displayName: 'Page', required: true, @@ -43,26 +232,30 @@ export const instagramCommon = { }; } - const accessToken: string = getAccessTokenOrThrow( - auth - ); - const pages: any[] = (await instagramCommon.getPages(accessToken)) - .map((page: FacebookPage) => { - if (!page.instagram_business_account) { - return null; - } - return { - label: page.name, - value: { - id: page.instagram_business_account.id, - accessToken: page.access_token, - }, - }; - }) - .filter((f: unknown) => f !== null); + const accessToken = getAccessTokenOrThrow(auth); + const pages = await getPages(accessToken); + const options = pages + .filter((page) => page.instagram_business_account !== undefined) + .map((page) => ({ + label: page.name, + value: { + id: page.instagram_business_account.id, + pageId: page.id, + accessToken: page.access_token, + }, + })); + + if (options.length === 0) { + return { + disabled: true, + options: [], + placeholder: + 'No Facebook Page with a linked Instagram professional account was found', + }; + } return { - options: pages, + options, placeholder: 'Choose a page', }; }, @@ -72,6 +265,13 @@ export const instagramCommon = { displayName: 'Caption', required: false, }), + + cursor: Property.ShortText({ + displayName: 'Cursor', + description: 'Next Cursor from a previous run, to fetch the following page.', + required: false, + }), + photo: Property.ShortText({ displayName: 'Photo', description: 'A URL we can access for the photo (JPG only)', @@ -83,124 +283,102 @@ export const instagramCommon = { description: 'A URL we can access for the video (Limit: 1GB or 15 minutes)', required: true, }), +}; - getPages: async (accessToken: string) => { - const response = await httpClient.sendRequest({ - method: HttpMethod.GET, - url: `${instagramCommon.baseUrl}/me/accounts`, - queryParams: { - access_token: accessToken, - fields: 'id,name,access_token,instagram_business_account', - }, - }); +export const COMMENT_FIELDS = + 'id,text,timestamp,username,like_count,hidden,parent_id,media{id,media_type,permalink}'; - return response.body.data; - }, - - createPhotoPost: async ( - page: FacebookPageDropdown, - caption: string | undefined, - photo: string - ) => { - const createContainerRequest = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${instagramCommon.baseUrl}/${page.id}/media`, - body: { - access_token: page.accessToken, - image_url: photo, - caption: caption, - }, - }); +export const MEDIA_FIELDS = + 'id,caption,media_type,media_product_type,media_url,permalink,thumbnail_url,timestamp,username,like_count,comments_count,is_comment_enabled'; - const publishContainerRequest = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${instagramCommon.baseUrl}/${page.id}/media_publish`, - body: { - access_token: page.accessToken, - creation_id: createContainerRequest.body.id, - }, - }); +export const INSTAGRAM_API_BASE = 'https://graph.facebook.com/v23.0'; - return publishContainerRequest.body; - }, - - createVideoPost: async ( - page: FacebookPageDropdown, - caption: string | undefined, - video: string - ) => { - const createContainerRequest = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${instagramCommon.baseUrl}/${page.id}/media`, - body: { - access_token: page.accessToken, - video_url: video, - caption: caption, - media_type: 'REELS', - }, - }); +export const CONTAINER_POLL_INTERVAL_MS = 5000; - const isUploaded = await isUploadSuccessful( - createContainerRequest.body.id, - page.accessToken, - 0 - ); +export const CONTAINER_TIMEOUT_MS = 5 * 60 * 1000; - if (isUploaded) { - const publishContainerRequest = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${instagramCommon.baseUrl}/${page.id}/media_publish`, - body: { - access_token: page.accessToken, - creation_id: createContainerRequest.body.id, - }, - }); - - return publishContainerRequest.body; - } else { - return false; +export const CAROUSEL_ALBUM_TIMEOUT_MS = 2 * 60 * 1000; + +export function parseArrayProp(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + return []; } - }, -}; + } -export interface FacebookPage { + return []; +} + +export type FacebookPage = { id: string; name: string; access_token: string; instagram_business_account: { id: string; }; -} +}; -export interface FacebookPageDropdown { +export type FacebookPageDropdown = { id: string; + pageId?: string; accessToken: string; -} +}; -async function isUploadSuccessful( - containerId: string, - accessToken: string, - retryCount: number -): Promise { - if (retryCount > 20) return false; +export type PublishedMedia = { + id: string; +}; - const request = await httpClient.sendRequest({ - method: HttpMethod.GET, - url: `${instagramCommon.baseUrl}/${containerId}`, - queryParams: { - access_token: accessToken, - fields: 'status_code', - }, - }); +type GraphRequestParams = { + method: HttpMethod; + resourceUri: string; + accessToken: string; + query?: Record; + body?: unknown; +}; - if (request.body.status_code != 'FINISHED') { - await _wait(5000); - return await isUploadSuccessful(containerId, accessToken, retryCount + 1); - } else { - return true; - } -} +type PublishMediaParams = { + page: FacebookPageDropdown; + body: Record; +}; -function _wait(n: number) { - return new Promise((resolve) => setTimeout(resolve, n)); -} +type CreateContainerParams = { + page: FacebookPageDropdown; + body: Record; +}; + +type WaitForContainerParams = { + containerId: string; + page: FacebookPageDropdown; + deadline?: number; +}; + +type PublishContainerParams = { + containerId: string; + page: FacebookPageDropdown; +}; + +type CreatePhotoPostParams = { + page: FacebookPageDropdown; + caption: string | undefined; + photo: string; +}; + +type CreateVideoPostParams = { + page: FacebookPageDropdown; + caption: string | undefined; + video: string; +}; + +export type GraphPaging = { + next?: string; + cursors?: { after?: string }; +}; diff --git a/packages/pieces/community/instagram-business/src/lib/output-schemas.ts b/packages/pieces/community/instagram-business/src/lib/output-schemas.ts new file mode 100644 index 000000000000..69903eb606e6 --- /dev/null +++ b/packages/pieces/community/instagram-business/src/lib/output-schemas.ts @@ -0,0 +1,313 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const mediaFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Media ID' }, + { key: 'caption', label: 'Caption' }, + { + key: 'media_type', + label: 'Media Type', + description: 'IMAGE, VIDEO or CAROUSEL_ALBUM.', + }, + { + key: 'media_product_type', + label: 'Product Type', + description: 'FEED, REELS or STORY.', + }, + { key: 'media_url', label: 'Media URL', format: 'image' }, + { + key: 'thumbnail_url', + label: 'Thumbnail URL', + description: 'Videos and reels only.', + format: 'image', + }, + { key: 'permalink', label: 'Permalink', format: 'url' }, + { key: 'timestamp', label: 'Published At', format: 'datetime' }, + { key: 'username', label: 'Username' }, + { key: 'like_count', label: 'Likes', format: 'number' }, + { key: 'comments_count', label: 'Comments', format: 'number' }, + { + key: 'is_comment_enabled', + label: 'Comments Enabled', + format: 'boolean', + }, +]; + +const storyFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Story ID' }, + { key: 'media_type', label: 'Media Type', description: 'IMAGE or VIDEO.' }, + { key: 'media_url', label: 'Media URL', format: 'image' }, + { + key: 'thumbnail_url', + label: 'Thumbnail URL', + description: 'Video stories only.', + format: 'image', + }, + { key: 'permalink', label: 'Permalink', format: 'url' }, + { key: 'timestamp', label: 'Published At', format: 'datetime' }, +]; + +export const publishedMediaOutputSchema: OutputSchema = { + fields: [ + { + key: 'id', + label: 'Media ID', + description: 'The id of the published post, usable with Get Media.', + }, + ], +}; + +export const getProfileOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Instagram Account ID' }, + { key: 'username', label: 'Username' }, + { key: 'name', label: 'Display Name' }, + { key: 'biography', label: 'Biography' }, + { key: 'website', label: 'Website', format: 'url' }, + { + key: 'profile_picture_url', + label: 'Profile Picture', + format: 'image', + }, + { key: 'followers_count', label: 'Followers', format: 'number' }, + { key: 'follows_count', label: 'Following', format: 'number' }, + { key: 'media_count', label: 'Media Count', format: 'number' }, + ], +}; + +export const listMediaOutputSchema: OutputSchema = { + fields: [ + { + key: 'media', + label: 'Media', + labelKey: 'caption', + listItems: mediaFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'next_cursor', + label: 'Next Cursor', + description: 'Pass to fetch the following page; absent on the last page.', + }, + ], +}; + +export const getMediaOutputSchema: OutputSchema = { + fields: mediaFields, +}; + +export const listCarouselChildrenOutputSchema: OutputSchema = { + fields: [ + { + key: 'children', + label: 'Children', + labelKey: 'media_type', + listItems: [ + { key: 'id', label: 'Media ID' }, + { key: 'media_type', label: 'Media Type' }, + { key: 'media_url', label: 'Media URL', format: 'image' }, + { key: 'thumbnail_url', label: 'Thumbnail URL', format: 'image' }, + { key: 'permalink', label: 'Permalink', format: 'url' }, + { key: 'timestamp', label: 'Published At', format: 'datetime' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const listStoriesOutputSchema: OutputSchema = { + fields: [ + { + key: 'stories', + label: 'Stories', + labelKey: 'id', + listItems: storyFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const getContentPublishingLimitOutputSchema: OutputSchema = { + fields: [ + { + key: 'quota_usage', + label: 'Quota Used', + description: 'Posts published through the API in the current window.', + format: 'number', + }, + { key: 'quota_total', label: 'Quota Total', format: 'number' }, + { key: 'quota_remaining', label: 'Quota Remaining', format: 'number' }, + { + key: 'quota_duration_seconds', + label: 'Window Length (seconds)', + description: 'Length of the rolling window, normally 86400.', + format: 'number', + }, + ], +}; + +const commentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Comment ID' }, + { key: 'text', label: 'Text' }, + { key: 'username', label: 'Author Username' }, + { key: 'timestamp', label: 'Posted At', format: 'datetime' }, + { key: 'like_count', label: 'Likes', format: 'number' }, + { key: 'hidden', label: 'Hidden', format: 'boolean' }, + { + key: 'parent_id', + label: 'Parent Comment ID', + description: 'Present only when this comment is a reply.', + }, + { + key: 'media', + label: 'Media', + children: [ + { key: 'id', label: 'Media ID' }, + { key: 'media_type', label: 'Media Type' }, + { key: 'permalink', label: 'Permalink', format: 'url' }, + ], + }, +]; + +export const identifierResultOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'id', label: 'ID' }, + ], +}; + +export const getCommentOutputSchema: OutputSchema = { + fields: commentFields, +}; + +export const listCommentsOutputSchema: OutputSchema = { + fields: [ + { + key: 'comments', + label: 'Comments', + labelKey: 'text', + listItems: commentFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_cursor', label: 'Next Cursor' }, + ], +}; + +export const commentCreatedOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Comment ID' }, + { key: 'media_id', label: 'Media ID' }, + { key: 'parent_id', label: 'Parent Comment ID' }, + ], +}; + +export const insightsOutputSchema: OutputSchema = { + fields: [ + { + key: 'insights', + label: 'Insights', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Metric ID' }, + { key: 'name', label: 'Metric' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'period', label: 'Period' }, + { + key: 'total_value', + label: 'Total Value', + children: [{ key: 'value', label: 'Value', format: 'number' }], + }, + { + key: 'values', + label: 'Values', + listItems: [ + { key: 'value', label: 'Value', format: 'number' }, + { key: 'end_time', label: 'End Time', format: 'datetime' }, + ], + }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const listConversationsOutputSchema: OutputSchema = { + fields: [ + { + key: 'conversations', + label: 'Conversations', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Conversation ID' }, + { key: 'updated_time', label: 'Last Activity', format: 'datetime' }, + { + key: 'participants', + label: 'Participants', + children: [ + { + key: 'data', + label: 'People', + labelKey: 'username', + listItems: [ + { key: 'id', label: 'User ID' }, + { key: 'username', label: 'Username' }, + ], + }, + ], + }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_cursor', label: 'Next Cursor' }, + ], +}; + +export const listMessagesOutputSchema: OutputSchema = { + fields: [ + { + key: 'messages', + label: 'Messages', + labelKey: 'message', + listItems: [ + { key: 'id', label: 'Message ID' }, + { + key: 'message', + label: 'Text', + description: 'Empty for attachment-only messages.', + }, + { key: 'created_time', label: 'Sent At', format: 'datetime' }, + { + key: 'from', + label: 'From', + children: [ + { key: 'id', label: 'User ID' }, + { key: 'username', label: 'Username' }, + ], + }, + { + key: 'to', + label: 'To', + children: [ + { + key: 'data', + label: 'Recipients', + labelKey: 'username', + listItems: [ + { key: 'id', label: 'User ID' }, + { key: 'username', label: 'Username' }, + ], + }, + ], + }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const sendMessageOutputSchema: OutputSchema = { + fields: [ + { key: 'message_id', label: 'Message ID' }, + { key: 'recipient_id', label: 'Recipient ID' }, + ], +};