@timesheet/plugin-asana 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Asana Sync
2
+
3
+ > Synchronize Timesheet todos and time entries with Asana.
4
+
5
+ Bidirectional synchronization between Timesheet and Asana. Timesheet todos are mirrored 1:1 with Asana tasks in the mapped project, and Timesheet time entries attached to a todo are logged as Asana time-tracking entries on the corresponding task. Changes made in Asana flow back through webhooks and scheduled syncs.
6
+
7
+ - Package: `@timesheet/plugin-asana`
8
+ - Manifest id: `asana-sync`
9
+ - Category: project-management
10
+
11
+ ## Authentication
12
+
13
+ OAuth 2.0 against Asana.
14
+
15
+ ## Configuration
16
+
17
+ | Option | Required | Default | Description |
18
+ | --- | --- | --- | --- |
19
+ | `syncDirection` | yes | `bidirectional` | One of `bidirectional`, `timesheet-to-asana`, `asana-to-timesheet`. |
20
+ | `workspaceId` | no | | Optional. When set, project lookups are scoped to this Asana workspace gid. |
21
+
22
+ ## Mappings
23
+
24
+ - **Project mapping**: Timesheet project to Asana project.
25
+
26
+ ## Triggers
27
+
28
+ - **Todo Changed** (event): syncs todo create, update, and delete events to Asana tasks.
29
+ - **Time Entry Changed** (event): logs Timesheet time entries as Asana time-tracking entries.
30
+ - **Asana Webhook** (webhook): receives inbound task changes from Asana.
31
+ - **Scheduled Full Sync** (daily at 02:00 UTC): runs a complete reconciliation sync.
32
+ - **Manual Sync** (user action): runs a full sync from the integration settings page.
33
+
34
+ ## Development
35
+
36
+ ```bash
37
+ npm install # or: npm ci
38
+ npm run build # compile TypeScript into dist/
39
+ npm run typecheck
40
+ ```
41
+
42
+ The package ships `dist/` and `manifest.json`; the sandboxed plugin runtime loads it by package name, version, and integrity. Shared tests live in `integrations/tests/` and run from the `integrations/` root with `npm test`.
@@ -0,0 +1,4 @@
1
+ import { SyncModeInput } from '@timesheet/integration-sdk';
2
+ import { AsanaConfig } from '../lib/types';
3
+ import { AsanaSyncResult } from '../lib/taskSync';
4
+ export declare const handleSyncBatch: import("@timesheet/integration-sdk").IntegrationHandler<SyncModeInput, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleSyncBatch = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'asana';
7
+ const PROJECT_ENTITY = 'project';
8
+ const TODO_ENTITY = 'todo';
9
+ const TASK_ENTITY = 'task';
10
+ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, context) => {
11
+ context.logger.info('Processing Asana sync batch', {
12
+ sinceVersion: input.sinceVersion,
13
+ headVersion: input.headVersion,
14
+ changeCount: input.changes.length,
15
+ hasMore: input.hasMore
16
+ });
17
+ const [projectMappings, todoMappings, taskMappings] = await Promise.all([
18
+ context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY }),
19
+ context.mappings.list({ system: SYSTEM, entity: TODO_ENTITY }),
20
+ context.mappings.list({ system: SYSTEM, entity: TASK_ENTITY })
21
+ ]);
22
+ const projectMappingByLocalId = new Map();
23
+ for (const m of projectMappings)
24
+ projectMappingByLocalId.set(m.localId, m);
25
+ const todoMappingByLocalId = new Map();
26
+ for (const m of todoMappings)
27
+ todoMappingByLocalId.set(m.localId, m);
28
+ const taskMappingByLocalId = new Map();
29
+ for (const m of taskMappings)
30
+ taskMappingByLocalId.set(m.localId, m);
31
+ const caches = { projectMappingByLocalId, todoMappingByLocalId, taskMappingByLocalId };
32
+ let syncedCount = 0;
33
+ const errors = [];
34
+ // Process todo changes first so any task changes within the same batch can
35
+ // find a fresh todo→Asana-task mapping to attach time entries to.
36
+ const orderedChanges = [...input.changes].sort((a, b) => {
37
+ if (a.entityType === b.entityType)
38
+ return 0;
39
+ if (a.entityType === 'todo')
40
+ return -1;
41
+ if (b.entityType === 'todo')
42
+ return 1;
43
+ return 0;
44
+ });
45
+ for (const change of orderedChanges) {
46
+ const op = change.op === 'DELETE' ? 'delete' : 'update';
47
+ const item = change.item;
48
+ try {
49
+ let result = null;
50
+ if (change.entityType === 'todo') {
51
+ result = await (0, taskSync_1.syncTodoToAsana)({
52
+ event: `todo.${op}`,
53
+ entityId: change.entityId,
54
+ item
55
+ }, context, caches);
56
+ }
57
+ else if (change.entityType === 'task') {
58
+ result = await (0, taskSync_1.syncTimesheetTaskToAsana)({
59
+ event: `task.${op}`,
60
+ taskId: change.entityId,
61
+ entityId: change.entityId,
62
+ item
63
+ }, context, caches);
64
+ }
65
+ else {
66
+ continue;
67
+ }
68
+ if (result.status === 'synced' || result.status === 'deleted') {
69
+ syncedCount++;
70
+ }
71
+ else if (result.status === 'skipped') {
72
+ context.logger.debug('Change skipped', {
73
+ entityId: change.entityId,
74
+ entityType: change.entityType,
75
+ reason: result.details?.reason
76
+ });
77
+ }
78
+ }
79
+ catch (err) {
80
+ context.logger.error('Failed to sync change', {
81
+ entityId: change.entityId,
82
+ entityType: change.entityType,
83
+ op: change.op,
84
+ error: String(err)
85
+ });
86
+ errors.push({ entityId: change.entityId, entityType: change.entityType, error: String(err) });
87
+ }
88
+ }
89
+ return {
90
+ system: SYSTEM,
91
+ status: errors.length > 0 ? 'partial' : 'completed',
92
+ syncedCount,
93
+ details: {
94
+ sinceVersion: input.sinceVersion,
95
+ headVersion: input.headVersion,
96
+ totalChanges: input.changes.length,
97
+ hasMore: input.hasMore,
98
+ errors: errors.length > 0 ? errors : undefined
99
+ }
100
+ };
101
+ });
@@ -0,0 +1,3 @@
1
+ import { AsanaConfig, SyncInput } from '../lib/types';
2
+ import { AsanaSyncResult } from '../lib/taskSync';
3
+ export declare const handleWebhook: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleWebhook = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.handleWebhook = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Handling Asana webhook', {
8
+ installationId: context.installationId,
9
+ hasBody: !!input?.body
10
+ });
11
+ return await (0, taskSync_1.handleAsanaWebhook)(input, context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { AsanaConfig } from '../lib/types';
3
+ export declare const listExternalProjects: import("@timesheet/integration-sdk").IntegrationHandler<void, ExternalEntity[], AsanaConfig>;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listExternalProjects = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'asana';
7
+ exports.listExternalProjects = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
8
+ context.logger.info('Listing Asana projects', {
9
+ system: SYSTEM,
10
+ installationId: context.installationId
11
+ });
12
+ const client = (0, taskSync_1.createAsanaClient)(context);
13
+ return await client.listProjects();
14
+ });
@@ -0,0 +1,3 @@
1
+ import { AsanaConfig } from '../lib/types';
2
+ import { AsanaSyncResult } from '../lib/taskSync';
3
+ export declare const runFullSync: import("@timesheet/integration-sdk").IntegrationHandler<void, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runFullSync = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.runFullSync = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
7
+ context.logger.info('Running Asana full sync', {
8
+ installationId: context.installationId,
9
+ syncDirection: context.config?.syncDirection ?? 'bidirectional'
10
+ });
11
+ return await (0, taskSync_1.runAsanaFullSync)(context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { AsanaConfig, SyncInput } from '../lib/types';
2
+ import { AsanaSyncResult } from '../lib/taskSync';
3
+ export declare const syncTaskFromExternal: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskFromExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTaskFromExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ // For Asana, inbound task/time-entry events flow through the same webhook
8
+ // pipeline — there's no first-class "fetch one entry" entry-point that's
9
+ // different from a generic webhook invocation.
10
+ context.logger.info('Syncing from Asana payload (legacy entry point)', {
11
+ installationId: context.installationId,
12
+ externalTaskId: input?.externalTaskId
13
+ });
14
+ return await (0, taskSync_1.handleAsanaWebhook)(input, context);
15
+ });
@@ -0,0 +1,3 @@
1
+ import { AsanaConfig, SyncInput } from '../lib/types';
2
+ import { AsanaSyncResult } from '../lib/taskSync';
3
+ export declare const syncTaskToExternal: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskToExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTaskToExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing Timesheet task → Asana time entry', {
8
+ installationId: context.installationId,
9
+ taskId: input?.taskId ?? input?.item?.id,
10
+ event: input?.event
11
+ });
12
+ return await (0, taskSync_1.syncTimesheetTaskToAsana)(input, context);
13
+ });
@@ -0,0 +1,3 @@
1
+ import { AsanaConfig, SyncInput } from '../lib/types';
2
+ import { AsanaSyncResult } from '../lib/taskSync';
3
+ export declare const syncTodoFromExternal: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTodoFromExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTodoFromExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing Timesheet todo from Asana payload', {
8
+ installationId: context.installationId,
9
+ externalTaskId: input?.externalTaskId
10
+ });
11
+ return await (0, taskSync_1.syncTodoFromAsana)(input, context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { AsanaConfig, SyncInput } from '../lib/types';
2
+ import { AsanaSyncResult } from '../lib/taskSync';
3
+ export declare const syncTodoToExternal: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, AsanaSyncResult, AsanaConfig>;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTodoToExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTodoToExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing Timesheet todo → Asana task', {
8
+ installationId: context.installationId,
9
+ todoId: input?.entityId ?? input?.item?.id,
10
+ event: input?.event
11
+ });
12
+ return await (0, taskSync_1.syncTodoToAsana)(input, context);
13
+ });
@@ -0,0 +1,6 @@
1
+ import { AsanaConfig } from '../lib/types';
2
+ export declare const testConnection: import("@timesheet/integration-sdk").IntegrationHandler<void, {
3
+ system: string;
4
+ ok: boolean;
5
+ installationId: string;
6
+ }, AsanaConfig>;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testConnection = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'asana';
7
+ exports.testConnection = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
8
+ context.logger.info('Testing Asana connection', {
9
+ system: SYSTEM,
10
+ installationId: context.installationId
11
+ });
12
+ const client = (0, taskSync_1.createAsanaClient)(context);
13
+ const ok = await client.testConnection();
14
+ return {
15
+ system: SYSTEM,
16
+ ok,
17
+ installationId: context.installationId
18
+ };
19
+ });
@@ -0,0 +1,11 @@
1
+ export { syncTaskToExternal } from './handlers/syncTaskToExternal';
2
+ export { syncTaskFromExternal } from './handlers/syncTaskFromExternal';
3
+ export { syncTodoToExternal } from './handlers/syncTodoToExternal';
4
+ export { syncTodoFromExternal } from './handlers/syncTodoFromExternal';
5
+ export { handleSyncBatch } from './handlers/handleSyncBatch';
6
+ export { handleWebhook } from './handlers/handleWebhook';
7
+ export { runFullSync } from './handlers/runFullSync';
8
+ export { testConnection } from './handlers/testConnection';
9
+ export { listExternalProjects } from './handlers/listExternalProjects';
10
+ export declare const PLUGIN_SYSTEM = "asana";
11
+ export declare const PLUGIN_NAME = "Asana Sync";
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_NAME = exports.PLUGIN_SYSTEM = exports.listExternalProjects = exports.testConnection = exports.runFullSync = exports.handleWebhook = exports.handleSyncBatch = exports.syncTodoFromExternal = exports.syncTodoToExternal = exports.syncTaskFromExternal = exports.syncTaskToExternal = void 0;
4
+ var syncTaskToExternal_1 = require("./handlers/syncTaskToExternal");
5
+ Object.defineProperty(exports, "syncTaskToExternal", { enumerable: true, get: function () { return syncTaskToExternal_1.syncTaskToExternal; } });
6
+ var syncTaskFromExternal_1 = require("./handlers/syncTaskFromExternal");
7
+ Object.defineProperty(exports, "syncTaskFromExternal", { enumerable: true, get: function () { return syncTaskFromExternal_1.syncTaskFromExternal; } });
8
+ var syncTodoToExternal_1 = require("./handlers/syncTodoToExternal");
9
+ Object.defineProperty(exports, "syncTodoToExternal", { enumerable: true, get: function () { return syncTodoToExternal_1.syncTodoToExternal; } });
10
+ var syncTodoFromExternal_1 = require("./handlers/syncTodoFromExternal");
11
+ Object.defineProperty(exports, "syncTodoFromExternal", { enumerable: true, get: function () { return syncTodoFromExternal_1.syncTodoFromExternal; } });
12
+ var handleSyncBatch_1 = require("./handlers/handleSyncBatch");
13
+ Object.defineProperty(exports, "handleSyncBatch", { enumerable: true, get: function () { return handleSyncBatch_1.handleSyncBatch; } });
14
+ var handleWebhook_1 = require("./handlers/handleWebhook");
15
+ Object.defineProperty(exports, "handleWebhook", { enumerable: true, get: function () { return handleWebhook_1.handleWebhook; } });
16
+ var runFullSync_1 = require("./handlers/runFullSync");
17
+ Object.defineProperty(exports, "runFullSync", { enumerable: true, get: function () { return runFullSync_1.runFullSync; } });
18
+ var testConnection_1 = require("./handlers/testConnection");
19
+ Object.defineProperty(exports, "testConnection", { enumerable: true, get: function () { return testConnection_1.testConnection; } });
20
+ var listExternalProjects_1 = require("./handlers/listExternalProjects");
21
+ Object.defineProperty(exports, "listExternalProjects", { enumerable: true, get: function () { return listExternalProjects_1.listExternalProjects; } });
22
+ exports.PLUGIN_SYSTEM = 'asana';
23
+ exports.PLUGIN_NAME = 'Asana Sync';
@@ -0,0 +1,46 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { AsanaTask, AsanaTimeTrackingEntry } from './types';
3
+ interface AsanaClientOptions {
4
+ getAccessToken: () => Promise<string>;
5
+ refreshAccessToken: () => Promise<string>;
6
+ workspaceId?: string;
7
+ }
8
+ export declare class AsanaClient {
9
+ private static readonly BASE_URL;
10
+ private static readonly REQUEST_TIMEOUT_MS;
11
+ private static readonly PAGE_LIMIT;
12
+ private readonly fetchAccessToken;
13
+ private readonly fetchRefreshedToken;
14
+ private cachedToken;
15
+ private readonly workspaceId;
16
+ constructor(options: AsanaClientOptions);
17
+ testConnection(): Promise<boolean>;
18
+ listWorkspaces(): Promise<ExternalEntity[]>;
19
+ listProjects(): Promise<ExternalEntity[]>;
20
+ listTasksInProject(projectId: string, modifiedSinceIso?: string): Promise<AsanaTask[]>;
21
+ getTask(taskId: string): Promise<AsanaTask | null>;
22
+ createTask(payload: Record<string, unknown>): Promise<AsanaTask>;
23
+ updateTask(taskId: string, payload: Record<string, unknown>): Promise<AsanaTask>;
24
+ deleteTask(taskId: string): Promise<void>;
25
+ listTimeTrackingEntries(taskId: string): Promise<AsanaTimeTrackingEntry[]>;
26
+ getTimeTrackingEntry(entryId: string): Promise<AsanaTimeTrackingEntry | null>;
27
+ createTimeTrackingEntry(taskId: string, payload: {
28
+ duration_minutes: number;
29
+ entered_on: string;
30
+ }): Promise<AsanaTimeTrackingEntry>;
31
+ updateTimeTrackingEntry(entryId: string, payload: Partial<{
32
+ duration_minutes: number;
33
+ entered_on: string;
34
+ }>): Promise<AsanaTimeTrackingEntry>;
35
+ deleteTimeTrackingEntry(entryId: string): Promise<void>;
36
+ createWebhook(resourceId: string, targetUrl: string): Promise<{
37
+ gid: string;
38
+ }>;
39
+ deleteWebhook(webhookGid: string): Promise<void>;
40
+ private paginate;
41
+ private getAccessToken;
42
+ private refreshAccessToken;
43
+ private request;
44
+ private buildUrl;
45
+ }
46
+ export {};
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AsanaClient = void 0;
4
+ class AsanaClient {
5
+ constructor(options) {
6
+ this.cachedToken = null;
7
+ this.fetchAccessToken = options.getAccessToken;
8
+ this.fetchRefreshedToken = options.refreshAccessToken;
9
+ this.workspaceId = options.workspaceId;
10
+ }
11
+ async testConnection() {
12
+ const me = await this.request('GET', '/users/me');
13
+ return !!me?.data?.gid;
14
+ }
15
+ async listWorkspaces() {
16
+ const workspaces = await this.paginate('/workspaces', {
17
+ opt_fields: 'name'
18
+ });
19
+ return workspaces.map((ws) => ({
20
+ id: ws.gid,
21
+ name: ws.name ?? ws.gid
22
+ }));
23
+ }
24
+ async listProjects() {
25
+ // If a workspace is configured, scope projects to it; otherwise list
26
+ // projects across all workspaces the token can see.
27
+ const workspaceIds = this.workspaceId
28
+ ? [this.workspaceId]
29
+ : (await this.listWorkspaces()).map((ws) => ws.id);
30
+ const out = [];
31
+ for (const wsId of workspaceIds) {
32
+ const projects = await this.paginate('/projects', {
33
+ workspace: wsId,
34
+ archived: 'false',
35
+ opt_fields: 'name,archived,workspace.name'
36
+ });
37
+ for (const project of projects) {
38
+ if (!project.gid)
39
+ continue;
40
+ const workspaceName = project.workspace?.name;
41
+ out.push({
42
+ id: project.gid,
43
+ name: workspaceName ? `${workspaceName} / ${project.name ?? project.gid}` : project.name ?? project.gid,
44
+ workspaceId: project.workspace?.gid ?? wsId,
45
+ archived: project.archived ?? false
46
+ });
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ async listTasksInProject(projectId, modifiedSinceIso) {
52
+ const query = {
53
+ opt_fields: 'name,notes,completed,completed_at,created_at,modified_at,due_at,due_on,start_at,start_on,assignee.gid,projects.gid,workspace.gid'
54
+ };
55
+ if (modifiedSinceIso) {
56
+ query.modified_since = modifiedSinceIso;
57
+ }
58
+ return this.paginate(`/projects/${encodeURIComponent(projectId)}/tasks`, query);
59
+ }
60
+ async getTask(taskId) {
61
+ try {
62
+ const response = await this.request('GET', `/tasks/${encodeURIComponent(taskId)}`, {
63
+ opt_fields: 'name,notes,completed,completed_at,created_at,modified_at,due_at,due_on,start_at,start_on,assignee.gid,projects.gid,workspace.gid'
64
+ });
65
+ return response?.data ?? null;
66
+ }
67
+ catch (err) {
68
+ if (String(err).includes('(404)')) {
69
+ return null;
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+ async createTask(payload) {
75
+ const response = await this.request('POST', '/tasks', undefined, { data: payload });
76
+ if (!response?.data?.gid) {
77
+ throw new Error('Asana createTask did not return a task gid.');
78
+ }
79
+ return response.data;
80
+ }
81
+ async updateTask(taskId, payload) {
82
+ const response = await this.request('PUT', `/tasks/${encodeURIComponent(taskId)}`, undefined, { data: payload });
83
+ if (!response?.data?.gid) {
84
+ throw new Error('Asana updateTask did not return a task gid.');
85
+ }
86
+ return response.data;
87
+ }
88
+ async deleteTask(taskId) {
89
+ await this.request('DELETE', `/tasks/${encodeURIComponent(taskId)}`);
90
+ }
91
+ async listTimeTrackingEntries(taskId) {
92
+ return this.paginate(`/tasks/${encodeURIComponent(taskId)}/time_tracking_entries`, { opt_fields: 'duration_minutes,entered_on,created_at,created_by.gid,task.gid' });
93
+ }
94
+ async getTimeTrackingEntry(entryId) {
95
+ try {
96
+ const response = await this.request('GET', `/time_tracking_entries/${encodeURIComponent(entryId)}`, { opt_fields: 'duration_minutes,entered_on,created_at,created_by.gid,task.gid' });
97
+ return response?.data ?? null;
98
+ }
99
+ catch (err) {
100
+ if (String(err).includes('(404)')) {
101
+ return null;
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+ async createTimeTrackingEntry(taskId, payload) {
107
+ const response = await this.request('POST', `/tasks/${encodeURIComponent(taskId)}/time_tracking_entries`, undefined, { data: payload });
108
+ if (!response?.data?.gid) {
109
+ throw new Error('Asana createTimeTrackingEntry did not return a gid.');
110
+ }
111
+ return response.data;
112
+ }
113
+ async updateTimeTrackingEntry(entryId, payload) {
114
+ const response = await this.request('PUT', `/time_tracking_entries/${encodeURIComponent(entryId)}`, undefined, { data: payload });
115
+ if (!response?.data?.gid) {
116
+ throw new Error('Asana updateTimeTrackingEntry did not return a gid.');
117
+ }
118
+ return response.data;
119
+ }
120
+ async deleteTimeTrackingEntry(entryId) {
121
+ await this.request('DELETE', `/time_tracking_entries/${encodeURIComponent(entryId)}`);
122
+ }
123
+ async createWebhook(resourceId, targetUrl) {
124
+ const response = await this.request('POST', '/webhooks', undefined, {
125
+ data: {
126
+ resource: resourceId,
127
+ target: targetUrl
128
+ }
129
+ });
130
+ if (!response?.data?.gid) {
131
+ throw new Error('Asana createWebhook did not return a webhook gid.');
132
+ }
133
+ return response.data;
134
+ }
135
+ async deleteWebhook(webhookGid) {
136
+ try {
137
+ await this.request('DELETE', `/webhooks/${encodeURIComponent(webhookGid)}`);
138
+ }
139
+ catch (err) {
140
+ // 404 means the hook was already cleaned up — not an error.
141
+ if (!String(err).includes('(404)')) {
142
+ throw err;
143
+ }
144
+ }
145
+ }
146
+ async paginate(path, baseQuery) {
147
+ const out = [];
148
+ let offset;
149
+ do {
150
+ const query = { ...baseQuery, limit: String(AsanaClient.PAGE_LIMIT) };
151
+ if (offset) {
152
+ query.offset = offset;
153
+ }
154
+ const response = await this.request('GET', path, query);
155
+ if (response?.data?.length) {
156
+ out.push(...response.data);
157
+ }
158
+ offset = response?.next_page?.offset ?? undefined;
159
+ } while (offset);
160
+ return out;
161
+ }
162
+ async getAccessToken() {
163
+ if (this.cachedToken) {
164
+ return this.cachedToken;
165
+ }
166
+ this.cachedToken = await this.fetchAccessToken();
167
+ return this.cachedToken;
168
+ }
169
+ async refreshAccessToken() {
170
+ this.cachedToken = null;
171
+ this.cachedToken = await this.fetchRefreshedToken();
172
+ return this.cachedToken;
173
+ }
174
+ async request(method, path, query, body, retried = false) {
175
+ const token = await this.getAccessToken();
176
+ const url = this.buildUrl(path, query);
177
+ const controller = new AbortController();
178
+ const timeoutId = setTimeout(() => controller.abort(), AsanaClient.REQUEST_TIMEOUT_MS);
179
+ let response;
180
+ try {
181
+ response = await fetch(url, {
182
+ method,
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ Accept: 'application/json',
186
+ 'Content-Type': 'application/json'
187
+ },
188
+ body: body !== undefined ? JSON.stringify(body) : undefined,
189
+ signal: controller.signal
190
+ });
191
+ }
192
+ catch (error) {
193
+ clearTimeout(timeoutId);
194
+ if (error instanceof DOMException && error.name === 'AbortError') {
195
+ throw new Error(`Asana API ${method} ${path} timed out after ${AsanaClient.REQUEST_TIMEOUT_MS}ms`);
196
+ }
197
+ throw error;
198
+ }
199
+ clearTimeout(timeoutId);
200
+ if (response.status === 401 && !retried) {
201
+ const refreshed = await this.refreshAccessToken();
202
+ if (refreshed) {
203
+ return this.request(method, path, query, body, true);
204
+ }
205
+ }
206
+ if (!response.ok) {
207
+ const errorBody = await response.text();
208
+ throw new Error(`Asana API ${method} ${path} failed (${response.status}): ${errorBody}`);
209
+ }
210
+ if (response.status === 204) {
211
+ return undefined;
212
+ }
213
+ return (await response.json());
214
+ }
215
+ buildUrl(path, query) {
216
+ const url = new URL(`${AsanaClient.BASE_URL}${path}`);
217
+ if (query) {
218
+ for (const [key, value] of Object.entries(query)) {
219
+ if (value === undefined || value === null || value === '') {
220
+ continue;
221
+ }
222
+ url.searchParams.append(key, String(value));
223
+ }
224
+ }
225
+ return url.toString();
226
+ }
227
+ }
228
+ exports.AsanaClient = AsanaClient;
229
+ AsanaClient.BASE_URL = 'https://app.asana.com/api/1.0';
230
+ AsanaClient.REQUEST_TIMEOUT_MS = 30000;
231
+ AsanaClient.PAGE_LIMIT = 100;
@@ -0,0 +1,21 @@
1
+ import { IntegrationContext, MappingRecord } from '@timesheet/integration-sdk';
2
+ import { AsanaClient } from './asanaClient';
3
+ import { AsanaConfig, SyncInput } from './types';
4
+ export interface AsanaSyncResult {
5
+ system: string;
6
+ status: string;
7
+ syncedCount: number;
8
+ details?: Record<string, unknown>;
9
+ }
10
+ export interface SyncBatchCaches {
11
+ projectMappingByLocalId?: Map<string, MappingRecord>;
12
+ todoMappingByLocalId?: Map<string, MappingRecord>;
13
+ taskMappingByLocalId?: Map<string, MappingRecord>;
14
+ }
15
+ export declare function resetSharedClient(): void;
16
+ export declare function createAsanaClient(context: IntegrationContext<AsanaConfig>): AsanaClient;
17
+ export declare function syncTodoToAsana(input: SyncInput, context: IntegrationContext<AsanaConfig>, caches?: SyncBatchCaches): Promise<AsanaSyncResult>;
18
+ export declare function syncTimesheetTaskToAsana(input: SyncInput, context: IntegrationContext<AsanaConfig>, caches?: SyncBatchCaches): Promise<AsanaSyncResult>;
19
+ export declare function runAsanaFullSync(context: IntegrationContext<AsanaConfig>): Promise<AsanaSyncResult>;
20
+ export declare function handleAsanaWebhook(input: SyncInput, context: IntegrationContext<AsanaConfig>): Promise<AsanaSyncResult>;
21
+ export declare function syncTodoFromAsana(input: SyncInput, context: IntegrationContext<AsanaConfig>): Promise<AsanaSyncResult>;