import type { JsonObject, JsonValue } from "type-fest" import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getAsanaApi } from "@automate.ax/integration-contracts/asana" import { ASANA_PAGE_INFO_SCHEMA, ASANA_CUSTOM_FIELD_INPUT_VALUE_SCHEMA, ASANA_PROVIDER_PAGE_SCHEMA, ASANA_PROVIDER_TASK_SCHEMA, ASANA_TASK_FIELDS, ASANA_TASK_SCHEMA, toAsanaPageInfo, toAsanaTask, } from "@automate.ax/integration-contracts/asana" import { ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE } from "./scopes" const TASK_RESPONSE_SCHEMA = z.looseObject({ data: ASANA_PROVIDER_TASK_SCHEMA, }) const TASK_PAGE_RESPONSE_SCHEMA = ASANA_PROVIDER_PAGE_SCHEMA.extend({ data: ASANA_PROVIDER_TASK_SCHEMA.array(), }) const EMPTY_RESPONSE_SCHEMA = z.looseObject({ data: z.looseObject({}) }) const TASK_PAGE_OUTPUT_SCHEMA = z.object({ /** Pagination metadata for requesting the next page. */ pageInfo: ASANA_PAGE_INFO_SCHEMA, /** Tasks in this page. */ tasks: ASANA_TASK_SCHEMA.array(), }) const TASK_MUTATION_SCHEMA = z .object({ /** User GID, email, `me`, or null to unassign. */ assignee: z.string().min(1).nullable().optional(), /** Task status within the assignee's My Tasks list. */ assigneeStatus: z .enum(["inbox", "later", "new", "today", "upcoming"]) .optional(), /** Whether the task is complete. */ completed: z.boolean().optional(), /** Custom field values keyed by custom-field GID. */ customFields: z .record(z.string(), ASANA_CUSTOM_FIELD_INPUT_VALUE_SCHEMA) .optional(), /** Due timestamp. Mutually exclusive with `dueOn`. */ dueAt: z .union([z.date(), z.iso.datetime({ offset: true })]) .nullable() .optional(), /** Due date. Mutually exclusive with `dueAt`. */ dueOn: z.iso.date().nullable().optional(), /** Rich-text task description. Mutually exclusive with `notes`. */ htmlNotes: z.string().optional(), /** Plain-text task description. Mutually exclusive with `htmlNotes`. */ notes: z.string().optional(), /** Approval, milestone, default task, or custom task subtype. */ subtype: z .enum(["approval", "custom", "default_task", "milestone"]) .optional(), /** Start timestamp. Mutually exclusive with `startOn`. */ startAt: z .union([z.date(), z.iso.datetime({ offset: true })]) .nullable() .optional(), /** Start date. Mutually exclusive with `startAt`. */ startOn: z.iso.date().nullable().optional(), }) .refine( ({ htmlNotes, notes }) => htmlNotes === undefined || notes === undefined, { message: "Provide only one of notes or htmlNotes.", path: ["notes"], }, ) .refine(({ dueAt, dueOn }) => dueAt === undefined || dueOn === undefined, { message: "Provide only one of dueAt or dueOn.", path: ["dueAt"], }) .refine( ({ startAt, startOn }) => startAt === undefined || startOn === undefined, { message: "Provide only one of startAt or startOn.", path: ["startAt"], }, ) .refine( ({ dueAt, startAt }) => startAt === undefined || dueAt !== undefined, { message: "dueAt is required when startAt is provided.", path: ["dueAt"], }, ) .refine( ({ dueAt, dueOn, startOn }) => startOn === undefined || dueAt !== undefined || dueOn !== undefined, { message: "dueAt or dueOn is required when startOn is provided.", path: ["dueOn"], }, ) const TASK_CREATION_SCHEMA = TASK_MUTATION_SCHEMA.safeExtend({ /** User GIDs to follow the task. */ followerIds: z.string().min(1).array().min(1).optional(), }) const CREATE_TASK_INPUT_SCHEMA = TASK_CREATION_SCHEMA.safeExtend({ /** Task name. */ name: z.string().min(1), /** Parent task GID. A parent, project, or workspace supplies task context. */ parentId: z.string().min(1).optional(), /** Project GIDs to add the task to at creation. */ projectIds: z.string().min(1).array().min(1).optional(), /** Workspace GID. Optional when a parent or project is supplied. */ workspaceId: z.string().min(1).optional(), }).refine( ({ parentId, projectIds, workspaceId }) => parentId !== undefined || projectIds !== undefined || workspaceId !== undefined, { message: "Provide workspaceId, at least one projectId, or parentId.", path: ["workspaceId"], }, ) const CREATE_SUBTASK_INPUT_SCHEMA = TASK_CREATION_SCHEMA.safeExtend({ /** Subtask name. */ name: z.string().min(1), /** Parent task GID. */ parentTaskId: z.string().min(1), }) const UPDATE_TASK_INPUT_SCHEMA = TASK_MUTATION_SCHEMA.safeExtend({ /** New task name. */ name: z.string().min(1).optional(), /** Task GID. */ taskId: z.string().min(1), }) const PAGE_INPUT_SCHEMA = z.object({ /** Maximum tasks to return. */ limit: z.number().int().min(1).max(100).prefault(100), /** Opaque token returned by the previous page. */ offset: z.string().min(1).optional(), }) const FOLLOWER_MUTATION_INPUT_SCHEMA = z.object({ /** User GIDs or `me`. */ followerIds: z.string().min(1).array().min(1), /** Task GID. */ taskId: z.string().min(1), }) /** Gets complete metadata for an Asana task. */ export const getAsanaTask = defineAction("Get Asana task") .describe("Gets complete metadata for an Asana task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(z.object({ taskId: z.string().min(1) })) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call(`/tasks/${input.taskId}`, { query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }) ).data, ), ) /** Lists tasks in an Asana project in project order. */ export const listAsanaProjectTasks = defineAction("List Asana project tasks") .describe("Lists tasks in an Asana project in project order.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( PAGE_INPUT_SCHEMA.extend({ /** * Return incomplete tasks and tasks completed since this timestamp or * `now`. */ completedSince: z .union([z.literal("now"), z.iso.datetime({ offset: true })]) .optional(), /** Project GID. */ projectId: z.string().min(1), }), ) .output(TASK_PAGE_OUTPUT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getTaskPage(account.secret, `/projects/${input.projectId}/tasks`, { completed_since: input.completedSince, limit: input.limit, offset: input.offset, }), ) /** Lists tasks in an Asana section in section order. */ export const listAsanaSectionTasks = defineAction("List Asana section tasks") .describe("Lists tasks in an Asana section in section order.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( PAGE_INPUT_SCHEMA.extend({ /** * Return incomplete tasks and tasks completed since this timestamp or * `now`. */ completedSince: z .union([z.literal("now"), z.iso.datetime({ offset: true })]) .optional(), /** Section GID. */ sectionId: z.string().min(1), }), ) .output(TASK_PAGE_OUTPUT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getTaskPage(account.secret, `/sections/${input.sectionId}/tasks`, { completed_since: input.completedSince, limit: input.limit, offset: input.offset, }), ) /** Lists tasks assigned to a user without requiring premium search. */ export const listAssignedAsanaTasks = defineAction("List assigned Asana tasks") .describe("Lists tasks assigned to a user in an Asana workspace.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( PAGE_INPUT_SCHEMA.extend({ /** User GID or `me` for the connected user. */ assignee: z.string().min(1).prefault("me"), /** * Return incomplete tasks and tasks completed since this timestamp or * `now`. */ completedSince: z .union([z.literal("now"), z.iso.datetime({ offset: true })]) .optional(), /** Workspace or organization GID. */ workspaceId: z.string().min(1), }), ) .output(TASK_PAGE_OUTPUT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getTaskPage(account.secret, "/tasks", { assignee: input.assignee, completed_since: input.completedSince, limit: input.limit, offset: input.offset, workspace: input.workspaceId, }), ) /** Lists the direct subtasks of an Asana task. */ export const listAsanaSubtasks = defineAction("List Asana subtasks") .describe("Lists the direct subtasks of an Asana task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( PAGE_INPUT_SCHEMA.extend({ /** Parent task GID. */ taskId: z.string().min(1), }), ) .output(TASK_PAGE_OUTPUT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getTaskPage(account.secret, `/tasks/${input.taskId}/subtasks`, { limit: input.limit, offset: input.offset, }), ) /** Searches tasks across an Asana workspace using advanced search. */ export const searchAsanaTasks = defineAction("Search Asana tasks") .describe("Searches tasks across a premium Asana workspace.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( z.object({ /** User GID or `me`; use `null` to find unassigned tasks. */ assignee: z.string().min(1).nullable().optional(), /** Filter by completion state. */ completed: z.boolean().optional(), /** Return tasks created after this timestamp. */ createdAfter: z.iso.datetime({ offset: true }).optional(), /** Return tasks due after this date. */ dueAfter: z.iso.date().optional(), /** Return tasks due before this date. */ dueBefore: z.iso.date().optional(), /** * Maximum matching tasks to return. Search results have no cursor * pagination. */ limit: z.number().int().min(1).max(100).prefault(100), /** Return tasks modified after this timestamp. */ modifiedAfter: z.iso.datetime({ offset: true }).optional(), /** Match tasks in any of these projects. */ projectIds: z.string().min(1).array().optional(), /** Match tasks in any of these sections. */ sectionIds: z.string().min(1).array().optional(), /** Sort direction. */ sortAscending: z.boolean().optional(), /** Search result sort field. */ sortBy: z .enum([ "completed_at", "created_at", "due_date", "likes", "modified_at", ]) .optional(), /** Full-text search across task names and descriptions. */ text: z.string().min(1).optional(), /** Workspace or organization GID. */ workspaceId: z.string().min(1), }), ) .output(z.object({ tasks: ASANA_TASK_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ({ tasks: ( await getAsanaApi(account.secret).call( `/workspaces/${input.workspaceId}/tasks/search`, { query: { "assignee.any": input.assignee === null ? "null" : input.assignee, completed: input.completed, "created_at.after": input.createdAfter, "due_on.after": input.dueAfter, "due_on.before": input.dueBefore, limit: input.limit, "modified_at.after": input.modifiedAfter, opt_fields: ASANA_TASK_FIELDS, "projects.any": input.projectIds?.join(","), "sections.any": input.sectionIds?.join(","), sort_ascending: input.sortAscending, sort_by: input.sortBy, text: input.text, }, responseSchema: TASK_PAGE_RESPONSE_SCHEMA, }, ) ).data.map(toAsanaTask), })) /** Creates an Asana task with optional project placement and assignment. */ export const createAsanaTask = defineAction("Create Asana task") .describe("Creates a task with optional project placement and assignment.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(CREATE_TASK_INPUT_SCHEMA) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call("/tasks", { body: { ...toTaskMutationBody(input), ...(input.followerIds && { followers: input.followerIds }), name: input.name, ...(input.parentId && { parent: input.parentId }), ...(input.projectIds && { projects: input.projectIds }), ...(input.workspaceId && { workspace: input.workspaceId }), }, query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }) ).data, ), ) /** Creates a direct subtask beneath an Asana task. */ export const createAsanaSubtask = defineAction("Create Asana subtask") .describe("Creates a direct subtask beneath an Asana task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(CREATE_SUBTASK_INPUT_SCHEMA) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call( `/tasks/${input.parentTaskId}/subtasks`, { body: { ...toTaskMutationBody(input), ...(input.followerIds && { followers: input.followerIds }), name: input.name, }, query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }, ) ).data, ), ) /** Updates selected fields on an Asana task. */ export const updateAsanaTask = defineAction("Update Asana task") .describe("Updates selected fields on an Asana task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(UPDATE_TASK_INPUT_SCHEMA) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call(`/tasks/${input.taskId}`, { body: { ...toTaskMutationBody(input), ...(input.name !== undefined && { name: input.name }), }, httpMethod: "PUT", query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }) ).data, ), ) /** Marks an Asana task complete or incomplete. */ export const setAsanaTaskCompletion = defineAction("Set Asana task completion") .describe("Marks an Asana task complete or incomplete.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( z.object({ /** Desired completion state. */ completed: z.boolean().prefault(true), /** Task GID. */ taskId: z.string().min(1), }), ) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call(`/tasks/${input.taskId}`, { body: { completed: input.completed }, httpMethod: "PUT", query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }) ).data, ), ) /** Adds an existing task to a project and optionally places it. */ export const addAsanaTaskToProject = defineAction("Add Asana task to project") .describe("Adds an existing task to a project and optionally positions it.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( z.object({ /** Task GID after which to insert the task. */ insertAfterTaskId: z.string().min(1).optional(), /** Task GID before which to insert the task. */ insertBeforeTaskId: z.string().min(1).optional(), /** Project GID. */ projectId: z.string().min(1), /** Section GID in the destination project. */ sectionId: z.string().min(1).optional(), /** Task GID. */ taskId: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getAsanaApi(account.secret).call( `/tasks/${input.taskId}/addProject`, { body: { ...(input.insertAfterTaskId && { insert_after: input.insertAfterTaskId, }), ...(input.insertBeforeTaskId && { insert_before: input.insertBeforeTaskId, }), project: input.projectId, ...(input.sectionId && { section: input.sectionId }), }, responseSchema: EMPTY_RESPONSE_SCHEMA, }, ) }) /** Removes a task from a project without deleting the task. */ export const removeAsanaTaskFromProject = defineAction( "Remove Asana task from project", ) .describe("Removes a task from a project without deleting the task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input( z.object({ /** Project GID. */ projectId: z.string().min(1), /** Task GID. */ taskId: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getAsanaApi(account.secret).call( `/tasks/${input.taskId}/removeProject`, { body: { project: input.projectId }, responseSchema: EMPTY_RESPONSE_SCHEMA, }, ) }) /** Adds one or more followers to an Asana task. */ export const addAsanaTaskFollowers = defineAction("Add Asana task followers") .describe("Adds one or more users as followers of an Asana task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(FOLLOWER_MUTATION_INPUT_SCHEMA) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call( `/tasks/${input.taskId}/addFollowers`, { body: { followers: input.followerIds }, query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }, ) ).data, ), ) /** Removes one or more followers from an Asana task. */ export const removeAsanaTaskFollowers = defineAction( "Remove Asana task followers", ) .describe("Removes one or more users from an Asana task's followers.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(FOLLOWER_MUTATION_INPUT_SCHEMA) .output(ASANA_TASK_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toAsanaTask( ( await getAsanaApi(account.secret).call( `/tasks/${input.taskId}/removeFollowers`, { body: { followers: input.followerIds }, query: { opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_RESPONSE_SCHEMA, }, ) ).data, ), ) /** Permanently deletes an Asana task. */ export const deleteAsanaTask = defineAction("Delete Asana task") .describe("Permanently deletes an Asana task.") .account(ASANA_ACCOUNT, ASANA_DEFAULT_SCOPE) .input(z.object({ taskId: z.string().min(1) })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getAsanaApi(account.secret).call(`/tasks/${input.taskId}`, { httpMethod: "DELETE", responseSchema: EMPTY_RESPONSE_SCHEMA, }) }) /** * Gets and normalizes one offset-paginated task page. * * @param secret - Resolved Asana integration secret. * @param path - Task collection API path. * @param query - Pagination and completed-task filter. * @param query.assignee - User whose tasks should be returned. * @param query.completed_since - Provider-native completion cutoff. * @param query.limit - Maximum tasks to return. * @param query.offset - Opaque next-page token. * @param query.workspace - Workspace containing assigned tasks. */ async function getTaskPage( secret: Record, path: string, query: { assignee?: string completed_since?: string limit: number offset?: string workspace?: string }, ) { const result = await getAsanaApi(secret).call(path, { query: { ...query, opt_fields: ASANA_TASK_FIELDS }, responseSchema: TASK_PAGE_RESPONSE_SCHEMA, }) return { pageInfo: toAsanaPageInfo(result), tasks: result.data.map(toAsanaTask), } } /** * Converts public task mutation fields to Asana's provider-native body. * * @param input - Validated public task mutation fields. */ function toTaskMutationBody( input: z.output, ): JsonObject { const customFields: Record | undefined = input.customFields ? Object.fromEntries( Object.entries(input.customFields).map(([fieldId, value]) => [ fieldId, toCustomFieldJsonValue(value), ]), ) : undefined return { ...(input.assignee !== undefined && { assignee: input.assignee }), ...(input.assigneeStatus !== undefined && { assignee_status: input.assigneeStatus, }), ...(input.completed !== undefined && { completed: input.completed }), ...(input.customFields !== undefined && { custom_fields: customFields, }), ...(input.dueAt !== undefined && { due_at: input.dueAt instanceof Date ? input.dueAt.toISOString() : input.dueAt, }), ...(input.dueOn !== undefined && { due_on: input.dueOn }), ...(input.htmlNotes !== undefined && { html_notes: input.htmlNotes }), ...(input.notes !== undefined && { notes: input.notes }), ...(input.startAt !== undefined && { start_at: input.startAt instanceof Date ? input.startAt.toISOString() : input.startAt, }), ...(input.startOn !== undefined && { start_on: input.startOn }), ...(input.subtype !== undefined && { resource_subtype: input.subtype }), } } /** * Converts one public custom-field input to provider JSON. * * @param value - Public value. */ function toCustomFieldJsonValue( value: z.output, ): JsonValue { if (!value || typeof value !== "object" || Array.isArray(value)) return value if (value.date) return { date: value.date } return { date_time: value.dateTime! } }