@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 +42 -0
- package/dist/handlers/handleSyncBatch.d.ts +4 -0
- package/dist/handlers/handleSyncBatch.js +101 -0
- package/dist/handlers/handleWebhook.d.ts +3 -0
- package/dist/handlers/handleWebhook.js +12 -0
- package/dist/handlers/listExternalProjects.d.ts +3 -0
- package/dist/handlers/listExternalProjects.js +14 -0
- package/dist/handlers/runFullSync.d.ts +3 -0
- package/dist/handlers/runFullSync.js +12 -0
- package/dist/handlers/syncTaskFromExternal.d.ts +3 -0
- package/dist/handlers/syncTaskFromExternal.js +15 -0
- package/dist/handlers/syncTaskToExternal.d.ts +3 -0
- package/dist/handlers/syncTaskToExternal.js +13 -0
- package/dist/handlers/syncTodoFromExternal.d.ts +3 -0
- package/dist/handlers/syncTodoFromExternal.js +12 -0
- package/dist/handlers/syncTodoToExternal.d.ts +3 -0
- package/dist/handlers/syncTodoToExternal.js +13 -0
- package/dist/handlers/testConnection.d.ts +6 -0
- package/dist/handlers/testConnection.js +19 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +23 -0
- package/dist/lib/asanaClient.d.ts +46 -0
- package/dist/lib/asanaClient.js +231 -0
- package/dist/lib/taskSync.d.ts +21 -0
- package/dist/lib/taskSync.js +853 -0
- package/dist/lib/types.d.ts +113 -0
- package/dist/lib/types.js +2 -0
- package/manifest.json +240 -0
- package/package.json +22 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resetSharedClient = resetSharedClient;
|
|
4
|
+
exports.createAsanaClient = createAsanaClient;
|
|
5
|
+
exports.syncTodoToAsana = syncTodoToAsana;
|
|
6
|
+
exports.syncTimesheetTaskToAsana = syncTimesheetTaskToAsana;
|
|
7
|
+
exports.runAsanaFullSync = runAsanaFullSync;
|
|
8
|
+
exports.handleAsanaWebhook = handleAsanaWebhook;
|
|
9
|
+
exports.syncTodoFromAsana = syncTodoFromAsana;
|
|
10
|
+
const integration_sdk_1 = require("@timesheet/integration-sdk");
|
|
11
|
+
const asanaClient_1 = require("./asanaClient");
|
|
12
|
+
const SYSTEM = 'asana';
|
|
13
|
+
const PROJECT_ENTITY = 'project';
|
|
14
|
+
const TODO_ENTITY = 'todo';
|
|
15
|
+
const TASK_ENTITY = 'task';
|
|
16
|
+
const SYNC_STATE_KEY = 'asana:last-sync-time';
|
|
17
|
+
// Import locks close the webhook-vs-full-sync race on first import; held for
|
|
18
|
+
// the TTL (not released on success) so duplicate webhook deliveries stay
|
|
19
|
+
// suppressed until the new mapping is visible everywhere.
|
|
20
|
+
const IMPORT_LOCK_TTL_SECONDS = 60 * 60;
|
|
21
|
+
const TODO_STATUS_OPEN = 0;
|
|
22
|
+
const TODO_STATUS_CLOSED = 1;
|
|
23
|
+
let sharedClient = null;
|
|
24
|
+
function resetSharedClient() {
|
|
25
|
+
sharedClient = null;
|
|
26
|
+
}
|
|
27
|
+
function createAsanaClient(context) {
|
|
28
|
+
return new asanaClient_1.AsanaClient({
|
|
29
|
+
getAccessToken: () => context.credentials.getAccessToken(SYSTEM),
|
|
30
|
+
refreshAccessToken: () => context.credentials.refreshToken(SYSTEM),
|
|
31
|
+
workspaceId: context.config?.workspaceId
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function getOrCreateClient(context) {
|
|
35
|
+
if (!sharedClient) {
|
|
36
|
+
sharedClient = createAsanaClient(context);
|
|
37
|
+
}
|
|
38
|
+
return sharedClient;
|
|
39
|
+
}
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Outbound: Timesheet ToDo → Asana Task
|
|
42
|
+
// ============================================================================
|
|
43
|
+
async function syncTodoToAsana(input, context, caches) {
|
|
44
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
45
|
+
if (syncDirection === 'asana-to-timesheet' || syncDirection === 'external-to-timesheet') {
|
|
46
|
+
return skip({ reason: 'sync-direction-mismatch' });
|
|
47
|
+
}
|
|
48
|
+
const todoId = resolveTodoId(input);
|
|
49
|
+
if (!todoId) {
|
|
50
|
+
return skip({ reason: 'missing-todo-id' });
|
|
51
|
+
}
|
|
52
|
+
const todo = await loadTodo(todoId, input, context);
|
|
53
|
+
if (!todo) {
|
|
54
|
+
return skip({ reason: 'todo-not-found', todoId });
|
|
55
|
+
}
|
|
56
|
+
const projectId = todo.project?.id;
|
|
57
|
+
if (!projectId) {
|
|
58
|
+
return skip({ reason: 'missing-project', todoId });
|
|
59
|
+
}
|
|
60
|
+
const projectMapping = await getMapping(context, caches?.projectMappingByLocalId, PROJECT_ENTITY, projectId);
|
|
61
|
+
if (!projectMapping?.externalId) {
|
|
62
|
+
return skip({ reason: 'missing-project-mapping', projectId });
|
|
63
|
+
}
|
|
64
|
+
const client = getOrCreateClient(context);
|
|
65
|
+
const todoMapping = await getMapping(context, caches?.todoMappingByLocalId, TODO_ENTITY, todo.id);
|
|
66
|
+
if (todo.deleted) {
|
|
67
|
+
if (todoMapping?.externalId) {
|
|
68
|
+
try {
|
|
69
|
+
await client.deleteTask(todoMapping.externalId);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
context.logger.warn('Failed to delete Asana task for deleted todo', {
|
|
73
|
+
externalId: todoMapping.externalId,
|
|
74
|
+
error: String(err)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
await context.mappings.delete({ system: SYSTEM, entity: TODO_ENTITY, localId: todo.id });
|
|
78
|
+
caches?.todoMappingByLocalId?.delete(todo.id);
|
|
79
|
+
return { system: SYSTEM, status: 'deleted', syncedCount: 1 };
|
|
80
|
+
}
|
|
81
|
+
return skip({ reason: 'already-deleted' });
|
|
82
|
+
}
|
|
83
|
+
// Echo guard: a change not newer than our own last write for this todo is
|
|
84
|
+
// the event fired by that write — syncing it back would ping-pong forever.
|
|
85
|
+
if (todoMapping?.externalId && (0, integration_sdk_1.isAlreadySyncedLocalChange)(todoMapping.metadata, (0, integration_sdk_1.getLastUpdateMillis)(todo))) {
|
|
86
|
+
return skip({ reason: 'already-synced-todo-change', todoId: todo.id });
|
|
87
|
+
}
|
|
88
|
+
const payload = buildAsanaTaskPayloadFromTodo(todo);
|
|
89
|
+
let external;
|
|
90
|
+
if (todoMapping?.externalId) {
|
|
91
|
+
const existing = await client.getTask(todoMapping.externalId);
|
|
92
|
+
if (existing?.gid) {
|
|
93
|
+
external = await client.updateTask(existing.gid, payload);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
external = await client.createTask({ ...payload, projects: [projectMapping.externalId] });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
external = await client.createTask({ ...payload, projects: [projectMapping.externalId] });
|
|
101
|
+
}
|
|
102
|
+
const upserted = {
|
|
103
|
+
localId: todo.id,
|
|
104
|
+
externalId: external.gid,
|
|
105
|
+
externalLabel: external.name ?? todo.name ?? todo.id,
|
|
106
|
+
metadata: {
|
|
107
|
+
projectId: projectMapping.externalId,
|
|
108
|
+
localProjectId: projectId,
|
|
109
|
+
...(0, integration_sdk_1.syncMetadataStamp)({
|
|
110
|
+
localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(todo),
|
|
111
|
+
externalUpdatedAt: external.modified_at,
|
|
112
|
+
externalUpdatedKey: 'modifiedAt'
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
syncStatus: 'SYNCED'
|
|
116
|
+
};
|
|
117
|
+
await context.mappings.upsert({ system: SYSTEM, entity: TODO_ENTITY, ...upserted });
|
|
118
|
+
caches?.todoMappingByLocalId?.set(todo.id, upserted);
|
|
119
|
+
return {
|
|
120
|
+
system: SYSTEM,
|
|
121
|
+
status: 'synced',
|
|
122
|
+
syncedCount: 1,
|
|
123
|
+
details: { todoId: todo.id, externalTaskGid: external.gid }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// Outbound: Timesheet Task (time entry) → Asana time_tracking_entry
|
|
128
|
+
// ============================================================================
|
|
129
|
+
async function syncTimesheetTaskToAsana(input, context, caches) {
|
|
130
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
131
|
+
if (syncDirection === 'asana-to-timesheet' || syncDirection === 'external-to-timesheet') {
|
|
132
|
+
return skip({ reason: 'sync-direction-mismatch' });
|
|
133
|
+
}
|
|
134
|
+
const taskId = resolveTaskId(input);
|
|
135
|
+
if (!taskId) {
|
|
136
|
+
return skip({ reason: 'missing-task-id' });
|
|
137
|
+
}
|
|
138
|
+
const task = await loadTask(taskId, input, context);
|
|
139
|
+
if (!task) {
|
|
140
|
+
return skip({ reason: 'task-not-found', taskId });
|
|
141
|
+
}
|
|
142
|
+
if (task.running) {
|
|
143
|
+
return skip({ reason: 'task-running', taskId });
|
|
144
|
+
}
|
|
145
|
+
const taskMapping = await getMapping(context, caches?.taskMappingByLocalId, TASK_ENTITY, task.id);
|
|
146
|
+
const client = getOrCreateClient(context);
|
|
147
|
+
// Echo guard: skip the event fired by our own inbound entry import/update.
|
|
148
|
+
if (!task.deleted && taskMapping?.externalId
|
|
149
|
+
&& (0, integration_sdk_1.isAlreadySyncedLocalChange)(taskMapping.metadata, (0, integration_sdk_1.getLastUpdateMillis)(task))) {
|
|
150
|
+
return skip({ reason: 'already-synced-task-change', taskId: task.id });
|
|
151
|
+
}
|
|
152
|
+
if (task.deleted) {
|
|
153
|
+
if (taskMapping?.externalId) {
|
|
154
|
+
try {
|
|
155
|
+
await client.deleteTimeTrackingEntry(taskMapping.externalId);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
context.logger.warn('Failed to delete Asana time-tracking entry', {
|
|
159
|
+
externalId: taskMapping.externalId,
|
|
160
|
+
error: String(err)
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
await context.mappings.delete({ system: SYSTEM, entity: TASK_ENTITY, localId: task.id });
|
|
164
|
+
caches?.taskMappingByLocalId?.delete(task.id);
|
|
165
|
+
return { system: SYSTEM, status: 'deleted', syncedCount: 1 };
|
|
166
|
+
}
|
|
167
|
+
return skip({ reason: 'already-deleted' });
|
|
168
|
+
}
|
|
169
|
+
// A time-tracking entry can only exist on an Asana task — so we need a
|
|
170
|
+
// todo→Asana-task mapping. If the Timesheet task isn't linked to a todo,
|
|
171
|
+
// there's no target to log against.
|
|
172
|
+
const localTodoId = task.todo?.id;
|
|
173
|
+
if (!localTodoId) {
|
|
174
|
+
return skip({ reason: 'missing-todo-on-task', taskId });
|
|
175
|
+
}
|
|
176
|
+
const todoMapping = await getMapping(context, caches?.todoMappingByLocalId, TODO_ENTITY, localTodoId);
|
|
177
|
+
if (!todoMapping?.externalId) {
|
|
178
|
+
return skip({ reason: 'missing-todo-mapping', todoId: localTodoId });
|
|
179
|
+
}
|
|
180
|
+
const durationMinutes = computeDurationMinutes(task);
|
|
181
|
+
if (durationMinutes == null) {
|
|
182
|
+
return skip({ reason: 'invalid-task-duration', taskId: task.id });
|
|
183
|
+
}
|
|
184
|
+
if (durationMinutes <= 0) {
|
|
185
|
+
return skip({ reason: 'zero-duration', taskId: task.id });
|
|
186
|
+
}
|
|
187
|
+
const enteredOn = toEnteredOn(task.startDateTime);
|
|
188
|
+
if (!enteredOn) {
|
|
189
|
+
return skip({ reason: 'invalid-start-date', taskId: task.id });
|
|
190
|
+
}
|
|
191
|
+
let external;
|
|
192
|
+
if (taskMapping?.externalId) {
|
|
193
|
+
external = await client.updateTimeTrackingEntry(taskMapping.externalId, {
|
|
194
|
+
duration_minutes: durationMinutes,
|
|
195
|
+
entered_on: enteredOn
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
external = await client.createTimeTrackingEntry(todoMapping.externalId, {
|
|
200
|
+
duration_minutes: durationMinutes,
|
|
201
|
+
entered_on: enteredOn
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const upserted = {
|
|
205
|
+
localId: task.id,
|
|
206
|
+
externalId: external.gid,
|
|
207
|
+
externalLabel: `${durationMinutes}m on ${enteredOn}`,
|
|
208
|
+
metadata: {
|
|
209
|
+
asanaTaskGid: todoMapping.externalId,
|
|
210
|
+
todoId: localTodoId,
|
|
211
|
+
durationMinutes: String(durationMinutes),
|
|
212
|
+
enteredOn,
|
|
213
|
+
...(0, integration_sdk_1.syncMetadataStamp)({ localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(task) })
|
|
214
|
+
},
|
|
215
|
+
syncStatus: 'SYNCED'
|
|
216
|
+
};
|
|
217
|
+
await context.mappings.upsert({ system: SYSTEM, entity: TASK_ENTITY, ...upserted });
|
|
218
|
+
caches?.taskMappingByLocalId?.set(task.id, upserted);
|
|
219
|
+
return {
|
|
220
|
+
system: SYSTEM,
|
|
221
|
+
status: 'synced',
|
|
222
|
+
syncedCount: 1,
|
|
223
|
+
details: { taskId: task.id, externalEntryGid: external.gid, asanaTaskGid: todoMapping.externalId }
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Inbound: Asana → Timesheet (full sync + webhook handler)
|
|
228
|
+
// ============================================================================
|
|
229
|
+
async function runAsanaFullSync(context) {
|
|
230
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
231
|
+
const allowInbound = syncDirection !== 'timesheet-to-asana' && syncDirection !== 'timesheet-to-external';
|
|
232
|
+
const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
|
|
233
|
+
if (projectMappings.length === 0) {
|
|
234
|
+
return skip({ reason: 'missing-project-mappings' });
|
|
235
|
+
}
|
|
236
|
+
if (!allowInbound) {
|
|
237
|
+
return {
|
|
238
|
+
system: SYSTEM,
|
|
239
|
+
status: 'completed',
|
|
240
|
+
syncedCount: 0,
|
|
241
|
+
details: { syncDirection, reason: 'outbound-only' }
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const projectByExternalId = new Map(projectMappings.map((m) => [m.externalId, m.localId]));
|
|
245
|
+
const lastSyncTime = (await context.state.get(SYNC_STATE_KEY)) ?? undefined;
|
|
246
|
+
const startedAt = new Date().toISOString();
|
|
247
|
+
const client = createAsanaClient(context);
|
|
248
|
+
let syncedCount = 0;
|
|
249
|
+
for (const mapping of projectMappings) {
|
|
250
|
+
if (!mapping.externalId)
|
|
251
|
+
continue;
|
|
252
|
+
const asanaTasks = await client.listTasksInProject(mapping.externalId, lastSyncTime);
|
|
253
|
+
for (const asanaTask of asanaTasks) {
|
|
254
|
+
const synced = await upsertLocalTodoFromAsanaTask(context, asanaTask, projectByExternalId);
|
|
255
|
+
if (synced)
|
|
256
|
+
syncedCount += 1;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
await context.state.set(SYNC_STATE_KEY, startedAt);
|
|
260
|
+
return {
|
|
261
|
+
system: SYSTEM,
|
|
262
|
+
status: 'completed',
|
|
263
|
+
syncedCount,
|
|
264
|
+
details: { syncDirection, sinceIso: lastSyncTime ?? null, mappedProjects: projectMappings.length }
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function handleAsanaWebhook(input, context) {
|
|
268
|
+
// Asana webhook handshake: persist the secret on first POST so subsequent
|
|
269
|
+
// X-Hook-Signature checks pass. The runtime echoes the header back in the
|
|
270
|
+
// HTTP response.
|
|
271
|
+
const handshakeSecret = getHeader(input, 'x-hook-secret');
|
|
272
|
+
if (handshakeSecret) {
|
|
273
|
+
await context.state.set('asana:webhook-secret', handshakeSecret);
|
|
274
|
+
return { system: SYSTEM, status: 'handshake', syncedCount: 0, details: { hasSecret: true } };
|
|
275
|
+
}
|
|
276
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
277
|
+
if (syncDirection === 'timesheet-to-asana' || syncDirection === 'timesheet-to-external') {
|
|
278
|
+
return skip({ reason: 'sync-direction-mismatch' });
|
|
279
|
+
}
|
|
280
|
+
const storedSecret = (await context.state.get('asana:webhook-secret')) ?? context.config?.webhookSecret;
|
|
281
|
+
const signature = getHeader(input, 'x-hook-signature');
|
|
282
|
+
const rawBody = getRawBody(input);
|
|
283
|
+
if (storedSecret) {
|
|
284
|
+
if (!signature || !rawBody) {
|
|
285
|
+
return { system: SYSTEM, status: 'rejected', syncedCount: 0, details: { reason: 'missing-signature-or-body' } };
|
|
286
|
+
}
|
|
287
|
+
if (!(await verifyAsanaSignature(rawBody, signature, storedSecret))) {
|
|
288
|
+
context.logger.warn('Asana webhook rejected: signature mismatch');
|
|
289
|
+
return { system: SYSTEM, status: 'rejected', syncedCount: 0, details: { reason: 'invalid-signature' } };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const payload = parseWebhookPayload(input.body, rawBody);
|
|
293
|
+
const events = payload?.events ?? [];
|
|
294
|
+
if (events.length === 0) {
|
|
295
|
+
return { system: SYSTEM, status: 'ignored', syncedCount: 0, details: { reason: 'no-events' } };
|
|
296
|
+
}
|
|
297
|
+
const taskGids = new Set();
|
|
298
|
+
const deletedTaskGids = new Set();
|
|
299
|
+
const entryGids = new Set();
|
|
300
|
+
const deletedEntryGids = new Set();
|
|
301
|
+
for (const event of events) {
|
|
302
|
+
const resourceType = event?.resource?.resource_type;
|
|
303
|
+
const gid = event?.resource?.gid;
|
|
304
|
+
const action = event.action ?? 'changed';
|
|
305
|
+
if (!gid)
|
|
306
|
+
continue;
|
|
307
|
+
if (resourceType === 'task') {
|
|
308
|
+
if (action === 'deleted' || action === 'removed') {
|
|
309
|
+
deletedTaskGids.add(gid);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
taskGids.add(gid);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (resourceType === 'time_tracking_entry') {
|
|
316
|
+
if (action === 'deleted' || action === 'removed') {
|
|
317
|
+
deletedEntryGids.add(gid);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
entryGids.add(gid);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return processInboundChanges(context, {
|
|
325
|
+
taskGids,
|
|
326
|
+
deletedTaskGids,
|
|
327
|
+
entryGids,
|
|
328
|
+
deletedEntryGids
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
async function syncTodoFromAsana(input, context) {
|
|
332
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
333
|
+
if (syncDirection === 'timesheet-to-asana' || syncDirection === 'timesheet-to-external') {
|
|
334
|
+
return skip({ reason: 'sync-direction-mismatch' });
|
|
335
|
+
}
|
|
336
|
+
const externalTaskId = input?.externalTaskId;
|
|
337
|
+
if (!externalTaskId) {
|
|
338
|
+
return skip({ reason: 'missing-external-task-id' });
|
|
339
|
+
}
|
|
340
|
+
return processInboundChanges(context, {
|
|
341
|
+
taskGids: new Set([externalTaskId]),
|
|
342
|
+
deletedTaskGids: new Set(),
|
|
343
|
+
entryGids: new Set(),
|
|
344
|
+
deletedEntryGids: new Set()
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
async function processInboundChanges(context, changes) {
|
|
348
|
+
const totalEvents = changes.taskGids.size + changes.deletedTaskGids.size + changes.entryGids.size + changes.deletedEntryGids.size;
|
|
349
|
+
if (totalEvents === 0) {
|
|
350
|
+
return { system: SYSTEM, status: 'ignored', syncedCount: 0, details: { reason: 'no-changes' } };
|
|
351
|
+
}
|
|
352
|
+
const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
|
|
353
|
+
const projectByExternalId = new Map(projectMappings.map((m) => [m.externalId, m.localId]));
|
|
354
|
+
const client = getOrCreateClient(context);
|
|
355
|
+
let syncedCount = 0;
|
|
356
|
+
// 1) Asana task deletes → delete the local todo.
|
|
357
|
+
for (const gid of changes.deletedTaskGids) {
|
|
358
|
+
const removed = await deleteLocalTodoByExternalId(context, gid);
|
|
359
|
+
if (removed)
|
|
360
|
+
syncedCount += 1;
|
|
361
|
+
}
|
|
362
|
+
// 2) Asana task changes → upsert the local todo.
|
|
363
|
+
for (const gid of changes.taskGids) {
|
|
364
|
+
const asanaTask = await client.getTask(gid);
|
|
365
|
+
if (!asanaTask) {
|
|
366
|
+
const removed = await deleteLocalTodoByExternalId(context, gid);
|
|
367
|
+
if (removed)
|
|
368
|
+
syncedCount += 1;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const synced = await upsertLocalTodoFromAsanaTask(context, asanaTask, projectByExternalId);
|
|
372
|
+
if (synced)
|
|
373
|
+
syncedCount += 1;
|
|
374
|
+
}
|
|
375
|
+
// 3) Time-entry deletes → delete the local task.
|
|
376
|
+
for (const gid of changes.deletedEntryGids) {
|
|
377
|
+
const removed = await deleteLocalTaskByExternalId(context, gid);
|
|
378
|
+
if (removed)
|
|
379
|
+
syncedCount += 1;
|
|
380
|
+
}
|
|
381
|
+
// 4) Time-entry changes → upsert the local task.
|
|
382
|
+
for (const gid of changes.entryGids) {
|
|
383
|
+
const entry = await client.getTimeTrackingEntry(gid);
|
|
384
|
+
if (!entry) {
|
|
385
|
+
const removed = await deleteLocalTaskByExternalId(context, gid);
|
|
386
|
+
if (removed)
|
|
387
|
+
syncedCount += 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const synced = await upsertLocalTaskFromAsanaEntry(context, entry);
|
|
391
|
+
if (synced)
|
|
392
|
+
syncedCount += 1;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
system: SYSTEM,
|
|
396
|
+
status: 'completed',
|
|
397
|
+
syncedCount,
|
|
398
|
+
details: {
|
|
399
|
+
taskEvents: changes.taskGids.size,
|
|
400
|
+
taskDeletes: changes.deletedTaskGids.size,
|
|
401
|
+
entryEvents: changes.entryGids.size,
|
|
402
|
+
entryDeletes: changes.deletedEntryGids.size
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
async function upsertLocalTodoFromAsanaTask(context, asanaTask, projectByExternalId) {
|
|
407
|
+
if (!asanaTask?.gid)
|
|
408
|
+
return false;
|
|
409
|
+
const localProjectId = (asanaTask.projects ?? [])
|
|
410
|
+
.map((p) => p?.gid)
|
|
411
|
+
.filter((gid) => !!gid)
|
|
412
|
+
.map((gid) => projectByExternalId.get(gid))
|
|
413
|
+
.find((id) => !!id);
|
|
414
|
+
if (!localProjectId) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
const todoMapping = await context.mappings.findByExternal({
|
|
418
|
+
system: SYSTEM,
|
|
419
|
+
entity: TODO_ENTITY,
|
|
420
|
+
externalId: asanaTask.gid
|
|
421
|
+
});
|
|
422
|
+
const name = asanaTask.name?.trim() || `Asana task ${asanaTask.gid}`;
|
|
423
|
+
const description = asanaTask.notes?.trim() || undefined;
|
|
424
|
+
const status = asanaTask.completed ? TODO_STATUS_CLOSED : TODO_STATUS_OPEN;
|
|
425
|
+
const dueDate = asanaTask.due_on ?? (asanaTask.due_at ? asanaTask.due_at.slice(0, 10) : undefined);
|
|
426
|
+
if (!todoMapping?.localId) {
|
|
427
|
+
// Import lock: a webhook delivery racing a full sync must not create the
|
|
428
|
+
// same todo twice. Held for the TTL; released only when the create fails.
|
|
429
|
+
const lockKey = `import:todo:${asanaTask.gid}`;
|
|
430
|
+
if (!(await (0, integration_sdk_1.tryAcquireStateLock)(context.state, lockKey, IMPORT_LOCK_TTL_SECONDS))) {
|
|
431
|
+
context.logger.info('Asana todo import already in progress, skipping duplicate create', {
|
|
432
|
+
externalId: asanaTask.gid
|
|
433
|
+
});
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
let created;
|
|
437
|
+
try {
|
|
438
|
+
created = await context.data.createTodo({
|
|
439
|
+
projectId: localProjectId,
|
|
440
|
+
name,
|
|
441
|
+
description,
|
|
442
|
+
status,
|
|
443
|
+
dueDate
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
await (0, integration_sdk_1.releaseStateLock)(context.state, lockKey);
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
await context.mappings.upsert({
|
|
451
|
+
system: SYSTEM,
|
|
452
|
+
entity: TODO_ENTITY,
|
|
453
|
+
localId: created.id,
|
|
454
|
+
externalId: asanaTask.gid,
|
|
455
|
+
externalLabel: name,
|
|
456
|
+
metadata: {
|
|
457
|
+
projectId: asanaTask.projects?.[0]?.gid ?? '',
|
|
458
|
+
localProjectId,
|
|
459
|
+
...(0, integration_sdk_1.syncMetadataStamp)({
|
|
460
|
+
localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(created),
|
|
461
|
+
externalUpdatedAt: asanaTask.modified_at,
|
|
462
|
+
externalUpdatedKey: 'modifiedAt'
|
|
463
|
+
})
|
|
464
|
+
},
|
|
465
|
+
syncStatus: 'SYNCED'
|
|
466
|
+
});
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
// Echo guard: an external change not newer than what this mapping already
|
|
470
|
+
// recorded is the echo of our own outbound write (or a redelivery).
|
|
471
|
+
if ((0, integration_sdk_1.isStaleExternalChange)({
|
|
472
|
+
metadata: todoMapping.metadata,
|
|
473
|
+
metadataKey: 'modifiedAt',
|
|
474
|
+
externalUpdatedAt: asanaTask.modified_at
|
|
475
|
+
})) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
const existing = await context.data.getTodo(todoMapping.localId);
|
|
479
|
+
if ((0, integration_sdk_1.isStaleExternalChange)({
|
|
480
|
+
externalUpdatedAt: asanaTask.modified_at,
|
|
481
|
+
localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(existing)
|
|
482
|
+
})) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
const updated = await context.data.updateTodo(todoMapping.localId, {
|
|
486
|
+
name,
|
|
487
|
+
description,
|
|
488
|
+
status,
|
|
489
|
+
dueDate
|
|
490
|
+
});
|
|
491
|
+
await context.mappings.upsert({
|
|
492
|
+
system: SYSTEM,
|
|
493
|
+
entity: TODO_ENTITY,
|
|
494
|
+
localId: todoMapping.localId,
|
|
495
|
+
externalId: asanaTask.gid,
|
|
496
|
+
externalLabel: name,
|
|
497
|
+
metadata: {
|
|
498
|
+
projectId: asanaTask.projects?.[0]?.gid ?? '',
|
|
499
|
+
localProjectId,
|
|
500
|
+
...(0, integration_sdk_1.syncMetadataStamp)({
|
|
501
|
+
localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(updated),
|
|
502
|
+
externalUpdatedAt: asanaTask.modified_at,
|
|
503
|
+
externalUpdatedKey: 'modifiedAt'
|
|
504
|
+
})
|
|
505
|
+
},
|
|
506
|
+
syncStatus: 'SYNCED'
|
|
507
|
+
});
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
async function upsertLocalTaskFromAsanaEntry(context, entry) {
|
|
511
|
+
const asanaTaskGid = entry.task?.gid;
|
|
512
|
+
if (!asanaTaskGid)
|
|
513
|
+
return false;
|
|
514
|
+
const todoMapping = await context.mappings.findByExternal({
|
|
515
|
+
system: SYSTEM,
|
|
516
|
+
entity: TODO_ENTITY,
|
|
517
|
+
externalId: asanaTaskGid
|
|
518
|
+
});
|
|
519
|
+
if (!todoMapping?.localId) {
|
|
520
|
+
// The parent Asana task isn't synced as a Timesheet todo yet — nothing to attach to.
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
const localProjectId = readMetadataString(todoMapping.metadata ?? {}, 'localProjectId');
|
|
524
|
+
if (!localProjectId) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
const dateRange = entryToTaskDateRange(entry);
|
|
528
|
+
if (!dateRange)
|
|
529
|
+
return false;
|
|
530
|
+
const description = `Time logged in Asana (${entry.duration_minutes ?? 0}m)`;
|
|
531
|
+
const taskMapping = await context.mappings.findByExternal({
|
|
532
|
+
system: SYSTEM,
|
|
533
|
+
entity: TASK_ENTITY,
|
|
534
|
+
externalId: entry.gid
|
|
535
|
+
});
|
|
536
|
+
if (!taskMapping?.localId) {
|
|
537
|
+
// Import lock: a webhook delivery racing a full sync must not create the
|
|
538
|
+
// same task twice. Held for the TTL; released only when the create fails.
|
|
539
|
+
const lockKey = `import:task:${entry.gid}`;
|
|
540
|
+
if (!(await (0, integration_sdk_1.tryAcquireStateLock)(context.state, lockKey, IMPORT_LOCK_TTL_SECONDS))) {
|
|
541
|
+
context.logger.info('Asana entry import already in progress, skipping duplicate create', {
|
|
542
|
+
externalId: entry.gid
|
|
543
|
+
});
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
let created;
|
|
547
|
+
try {
|
|
548
|
+
created = await context.data.createTask({
|
|
549
|
+
projectId: localProjectId,
|
|
550
|
+
todoId: todoMapping.localId,
|
|
551
|
+
startDateTime: dateRange.startDateTime,
|
|
552
|
+
endDateTime: dateRange.endDateTime,
|
|
553
|
+
description
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
await (0, integration_sdk_1.releaseStateLock)(context.state, lockKey);
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
await context.mappings.upsert({
|
|
561
|
+
system: SYSTEM,
|
|
562
|
+
entity: TASK_ENTITY,
|
|
563
|
+
localId: created.id,
|
|
564
|
+
externalId: entry.gid,
|
|
565
|
+
externalLabel: description,
|
|
566
|
+
metadata: {
|
|
567
|
+
asanaTaskGid,
|
|
568
|
+
todoId: todoMapping.localId,
|
|
569
|
+
durationMinutes: String(entry.duration_minutes ?? 0),
|
|
570
|
+
enteredOn: entry.entered_on ?? '',
|
|
571
|
+
...(0, integration_sdk_1.syncMetadataStamp)({ localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(created) })
|
|
572
|
+
},
|
|
573
|
+
syncStatus: 'SYNCED'
|
|
574
|
+
});
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
const existing = await context.data.getTask(taskMapping.localId);
|
|
578
|
+
// Entries expose no modified timestamp — created_at vs the task's own
|
|
579
|
+
// lastUpdate is the only staleness signal available.
|
|
580
|
+
if ((0, integration_sdk_1.isStaleExternalChange)({
|
|
581
|
+
externalUpdatedAt: entry.created_at,
|
|
582
|
+
localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(existing)
|
|
583
|
+
})) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
const updated = await context.data.updateTask(taskMapping.localId, {
|
|
587
|
+
projectId: localProjectId,
|
|
588
|
+
todoId: todoMapping.localId,
|
|
589
|
+
startDateTime: dateRange.startDateTime,
|
|
590
|
+
endDateTime: dateRange.endDateTime,
|
|
591
|
+
description
|
|
592
|
+
});
|
|
593
|
+
await context.mappings.upsert({
|
|
594
|
+
system: SYSTEM,
|
|
595
|
+
entity: TASK_ENTITY,
|
|
596
|
+
localId: taskMapping.localId,
|
|
597
|
+
externalId: entry.gid,
|
|
598
|
+
externalLabel: description,
|
|
599
|
+
metadata: {
|
|
600
|
+
asanaTaskGid,
|
|
601
|
+
todoId: todoMapping.localId,
|
|
602
|
+
durationMinutes: String(entry.duration_minutes ?? 0),
|
|
603
|
+
enteredOn: entry.entered_on ?? '',
|
|
604
|
+
...(0, integration_sdk_1.syncMetadataStamp)({ localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(updated) })
|
|
605
|
+
},
|
|
606
|
+
syncStatus: 'SYNCED'
|
|
607
|
+
});
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
async function deleteLocalTodoByExternalId(context, externalId) {
|
|
611
|
+
const mapping = await context.mappings.findByExternal({
|
|
612
|
+
system: SYSTEM,
|
|
613
|
+
entity: TODO_ENTITY,
|
|
614
|
+
externalId
|
|
615
|
+
});
|
|
616
|
+
if (!mapping?.localId)
|
|
617
|
+
return false;
|
|
618
|
+
try {
|
|
619
|
+
await context.data.deleteTodo(mapping.localId);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
context.logger.warn('Failed to delete local todo for Asana task delete', {
|
|
623
|
+
localId: mapping.localId,
|
|
624
|
+
externalId,
|
|
625
|
+
error: String(err)
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
await context.mappings.delete({ system: SYSTEM, entity: TODO_ENTITY, localId: mapping.localId });
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
async function deleteLocalTaskByExternalId(context, externalId) {
|
|
632
|
+
const mapping = await context.mappings.findByExternal({
|
|
633
|
+
system: SYSTEM,
|
|
634
|
+
entity: TASK_ENTITY,
|
|
635
|
+
externalId
|
|
636
|
+
});
|
|
637
|
+
if (!mapping?.localId)
|
|
638
|
+
return false;
|
|
639
|
+
try {
|
|
640
|
+
await context.data.deleteTask(mapping.localId);
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
context.logger.warn('Failed to delete local task for Asana entry delete', {
|
|
644
|
+
localId: mapping.localId,
|
|
645
|
+
externalId,
|
|
646
|
+
error: String(err)
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
await context.mappings.delete({ system: SYSTEM, entity: TASK_ENTITY, localId: mapping.localId });
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
// ============================================================================
|
|
653
|
+
// Helpers
|
|
654
|
+
// ============================================================================
|
|
655
|
+
async function getMapping(context, cache, entity, localId) {
|
|
656
|
+
if (cache) {
|
|
657
|
+
return cache.get(localId) ?? null;
|
|
658
|
+
}
|
|
659
|
+
return context.mappings.get({ system: SYSTEM, entity, localId });
|
|
660
|
+
}
|
|
661
|
+
async function loadTask(taskId, input, context) {
|
|
662
|
+
if (input?.item && typeof input.item === 'object' && hasTaskShape(input.item)) {
|
|
663
|
+
const raw = input.item;
|
|
664
|
+
const projectId = raw.projectId;
|
|
665
|
+
if (!raw.project && projectId)
|
|
666
|
+
raw.project = { id: projectId };
|
|
667
|
+
const todoId = raw.todoId;
|
|
668
|
+
if (!raw.todo && todoId)
|
|
669
|
+
raw.todo = { id: todoId };
|
|
670
|
+
if (!raw.id && raw.taskId)
|
|
671
|
+
raw.id = raw.taskId;
|
|
672
|
+
return raw;
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
return await context.data.getTask(taskId);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async function loadTodo(todoId, input, context) {
|
|
682
|
+
if (input?.item && typeof input.item === 'object' && hasTodoShape(input.item)) {
|
|
683
|
+
const raw = input.item;
|
|
684
|
+
const projectId = raw.projectId;
|
|
685
|
+
if (!raw.project && projectId)
|
|
686
|
+
raw.project = { id: projectId };
|
|
687
|
+
if (!raw.id && raw.todoId)
|
|
688
|
+
raw.id = raw.todoId;
|
|
689
|
+
return raw;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
return await context.data.getTodo(todoId);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function hasTaskShape(item) {
|
|
699
|
+
// Tasks always have a start/end datetime; todos don't.
|
|
700
|
+
return 'startDateTime' in item || 'endDateTime' in item || 'taskId' in item || 'running' in item;
|
|
701
|
+
}
|
|
702
|
+
function hasTodoShape(item) {
|
|
703
|
+
return 'name' in item || 'todoId' in item || 'estimatedHours' in item;
|
|
704
|
+
}
|
|
705
|
+
function resolveTaskId(input) {
|
|
706
|
+
return (input?.taskId ||
|
|
707
|
+
input?.entityId ||
|
|
708
|
+
input?.item?.taskId ||
|
|
709
|
+
input?.item?.id);
|
|
710
|
+
}
|
|
711
|
+
function resolveTodoId(input) {
|
|
712
|
+
return (input?.entityId ||
|
|
713
|
+
input?.item?.todoId ||
|
|
714
|
+
input?.item?.id);
|
|
715
|
+
}
|
|
716
|
+
function buildAsanaTaskPayloadFromTodo(todo) {
|
|
717
|
+
const name = todo.name?.trim() || `Todo ${todo.id}`;
|
|
718
|
+
const notes = todo.description ?? '';
|
|
719
|
+
const completed = todo.status === TODO_STATUS_CLOSED;
|
|
720
|
+
const payload = {
|
|
721
|
+
name,
|
|
722
|
+
notes,
|
|
723
|
+
completed
|
|
724
|
+
};
|
|
725
|
+
if (todo.dueDate) {
|
|
726
|
+
// Asana expects ISO date (YYYY-MM-DD) for due_on; full datetime for due_at.
|
|
727
|
+
payload.due_on = todo.dueDate.length > 10 ? todo.dueDate.slice(0, 10) : todo.dueDate;
|
|
728
|
+
}
|
|
729
|
+
return payload;
|
|
730
|
+
}
|
|
731
|
+
function computeDurationMinutes(task) {
|
|
732
|
+
// task.duration and task.durationBreak are in seconds; Asana wants whole
|
|
733
|
+
// minutes, so net worked seconds convert with a /60 divisor.
|
|
734
|
+
if (typeof task.duration === 'number' && task.duration > 0) {
|
|
735
|
+
return Math.max(0, Math.round((task.duration - (task.durationBreak ?? 0)) / 60));
|
|
736
|
+
}
|
|
737
|
+
const start = parseDate(task.startDateTime);
|
|
738
|
+
const end = parseDate(task.endDateTime);
|
|
739
|
+
if (!start || !end)
|
|
740
|
+
return null;
|
|
741
|
+
// The start/end delta is milliseconds; durationBreak (seconds) is scaled to
|
|
742
|
+
// match before subtracting.
|
|
743
|
+
const ms = end.getTime() - start.getTime() - (task.durationBreak ?? 0) * 1000;
|
|
744
|
+
return ms <= 0 ? 0 : Math.round(ms / 60000);
|
|
745
|
+
}
|
|
746
|
+
function toEnteredOn(value) {
|
|
747
|
+
if (!value)
|
|
748
|
+
return null;
|
|
749
|
+
const d = new Date(value);
|
|
750
|
+
if (Number.isNaN(d.getTime()))
|
|
751
|
+
return null;
|
|
752
|
+
return d.toISOString().slice(0, 10);
|
|
753
|
+
}
|
|
754
|
+
function entryToTaskDateRange(entry) {
|
|
755
|
+
if (!entry.entered_on)
|
|
756
|
+
return null;
|
|
757
|
+
const start = new Date(`${entry.entered_on}T00:00:00Z`);
|
|
758
|
+
if (Number.isNaN(start.getTime()))
|
|
759
|
+
return null;
|
|
760
|
+
const duration = Number(entry.duration_minutes ?? 0);
|
|
761
|
+
if (!Number.isFinite(duration) || duration < 0)
|
|
762
|
+
return null;
|
|
763
|
+
const end = new Date(start.getTime() + duration * 60000);
|
|
764
|
+
return { startDateTime: start.toISOString(), endDateTime: end.toISOString() };
|
|
765
|
+
}
|
|
766
|
+
function parseDate(value) {
|
|
767
|
+
if (!value)
|
|
768
|
+
return null;
|
|
769
|
+
const d = new Date(value);
|
|
770
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
771
|
+
}
|
|
772
|
+
function getHeader(input, name) {
|
|
773
|
+
const headers = { ...(input?.headers ?? {}) };
|
|
774
|
+
if (input?.body && typeof input.body === 'object') {
|
|
775
|
+
const nested = input.body.headers;
|
|
776
|
+
if (nested && typeof nested === 'object') {
|
|
777
|
+
Object.assign(headers, nested);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const target = name.toLowerCase();
|
|
781
|
+
const key = Object.keys(headers).find((header) => header.toLowerCase() === target);
|
|
782
|
+
if (!key)
|
|
783
|
+
return undefined;
|
|
784
|
+
const value = headers[key];
|
|
785
|
+
return value === undefined || value === null ? undefined : String(value);
|
|
786
|
+
}
|
|
787
|
+
function getRawBody(input) {
|
|
788
|
+
if (typeof input?.rawBody === 'string' && input.rawBody.length > 0)
|
|
789
|
+
return input.rawBody;
|
|
790
|
+
if (typeof input?.body === 'string' && input.body.length > 0)
|
|
791
|
+
return input.body;
|
|
792
|
+
if (input?.body && typeof input.body === 'object') {
|
|
793
|
+
try {
|
|
794
|
+
return JSON.stringify(input.body);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
function parseWebhookPayload(body, rawBody) {
|
|
803
|
+
if (body && typeof body === 'object')
|
|
804
|
+
return body;
|
|
805
|
+
if (rawBody) {
|
|
806
|
+
try {
|
|
807
|
+
return JSON.parse(rawBody);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
// Web Crypto is used so this plugin works in sandboxed runtimes (esbuild bundler
|
|
816
|
+
// without Node built-ins). Both Node 18+ and the plugin runtime expose
|
|
817
|
+
// `globalThis.crypto.subtle`.
|
|
818
|
+
async function verifyAsanaSignature(rawBody, signatureHeader, secret) {
|
|
819
|
+
// Asana signs the raw request body with HMAC-SHA256 keyed on the webhook
|
|
820
|
+
// secret, and sends the digest as lowercase hex in `x-hook-signature`.
|
|
821
|
+
const encoder = new TextEncoder();
|
|
822
|
+
const key = await globalThis.crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
823
|
+
const signatureBuffer = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(rawBody));
|
|
824
|
+
const expected = bufferToHex(signatureBuffer);
|
|
825
|
+
return constantTimeEquals(expected, signatureHeader);
|
|
826
|
+
}
|
|
827
|
+
function bufferToHex(buffer) {
|
|
828
|
+
const bytes = new Uint8Array(buffer);
|
|
829
|
+
let hex = '';
|
|
830
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
831
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
832
|
+
}
|
|
833
|
+
return hex;
|
|
834
|
+
}
|
|
835
|
+
function constantTimeEquals(a, b) {
|
|
836
|
+
if (a.length !== b.length) {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
let result = 0;
|
|
840
|
+
for (let i = 0; i < a.length; i++) {
|
|
841
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
842
|
+
}
|
|
843
|
+
return result === 0;
|
|
844
|
+
}
|
|
845
|
+
function readMetadataString(metadata, key) {
|
|
846
|
+
const value = metadata[key];
|
|
847
|
+
if (typeof value === 'string' && value.trim().length > 0)
|
|
848
|
+
return value;
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
function skip(details) {
|
|
852
|
+
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details };
|
|
853
|
+
}
|