@timesheet/plugin-google-calendar 1.0.0 → 1.0.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.
|
@@ -8,5 +8,8 @@ exports.runFullSync = (0, integration_sdk_1.defineHandler)(async (_input, contex
|
|
|
8
8
|
installationId: context.installationId,
|
|
9
9
|
syncDirection: context.config?.syncDirection ?? 'bidirectional'
|
|
10
10
|
});
|
|
11
|
-
|
|
11
|
+
const result = await (0, taskSync_1.runGoogleCalendarFullSync)(context);
|
|
12
|
+
// Ensure watch channels are active for inbound push notifications
|
|
13
|
+
await (0, taskSync_1.ensureWatchChannels)(context);
|
|
14
|
+
return result;
|
|
12
15
|
});
|
|
@@ -15,6 +15,12 @@ export declare class GoogleCalendarClient {
|
|
|
15
15
|
createEvent(calendarId: string, payload: Record<string, unknown>): Promise<GoogleCalendarEvent>;
|
|
16
16
|
updateEvent(calendarId: string, eventId: string, payload: Record<string, unknown>): Promise<GoogleCalendarEvent>;
|
|
17
17
|
deleteEvent(calendarId: string, eventId: string): Promise<void>;
|
|
18
|
+
watchEvents(calendarId: string, channelId: string, webhookUrl: string, ttlSeconds?: number): Promise<{
|
|
19
|
+
id?: string;
|
|
20
|
+
resourceId?: string;
|
|
21
|
+
expiration?: string;
|
|
22
|
+
}>;
|
|
23
|
+
stopWatch(channelId: string, resourceId: string): Promise<void>;
|
|
18
24
|
private request;
|
|
19
25
|
private buildUrl;
|
|
20
26
|
}
|
|
@@ -54,6 +54,30 @@ class GoogleCalendarClient {
|
|
|
54
54
|
async deleteEvent(calendarId, eventId) {
|
|
55
55
|
await this.request('DELETE', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`);
|
|
56
56
|
}
|
|
57
|
+
async watchEvents(calendarId, channelId, webhookUrl, ttlSeconds = 604800) {
|
|
58
|
+
return this.request('POST', `/calendars/${encodeURIComponent(calendarId)}/events/watch`, undefined, {
|
|
59
|
+
id: channelId,
|
|
60
|
+
type: 'web_hook',
|
|
61
|
+
address: webhookUrl,
|
|
62
|
+
params: { ttl: String(ttlSeconds) }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async stopWatch(channelId, resourceId) {
|
|
66
|
+
const token = await this.getAccessToken();
|
|
67
|
+
const response = await fetch('https://www.googleapis.com/calendar/v3/channels/stop', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${token}`,
|
|
71
|
+
'Content-Type': 'application/json'
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ id: channelId, resourceId })
|
|
74
|
+
});
|
|
75
|
+
// 404 means channel already expired — not an error
|
|
76
|
+
if (!response.ok && response.status !== 404) {
|
|
77
|
+
const errorText = await response.text();
|
|
78
|
+
throw new Error(`Google Calendar API channels/stop failed (${response.status}): ${errorText}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
57
81
|
async request(method, path, query, body, retried = false) {
|
|
58
82
|
const token = await this.getAccessToken();
|
|
59
83
|
const response = await fetch(this.buildUrl(path, query), {
|
package/dist/lib/taskSync.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export interface GoogleCalendarSyncResult {
|
|
|
9
9
|
export declare function syncTaskToGoogleCalendar(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
|
|
10
10
|
export declare function runGoogleCalendarFullSync(context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
|
|
11
11
|
export declare function handleGoogleWebhook(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
|
|
12
|
+
export declare function ensureWatchChannels(context: IntegrationContext<GoogleCalendarConfig>): Promise<void>;
|
package/dist/lib/taskSync.js
CHANGED
|
@@ -3,11 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.syncTaskToGoogleCalendar = syncTaskToGoogleCalendar;
|
|
4
4
|
exports.runGoogleCalendarFullSync = runGoogleCalendarFullSync;
|
|
5
5
|
exports.handleGoogleWebhook = handleGoogleWebhook;
|
|
6
|
+
exports.ensureWatchChannels = ensureWatchChannels;
|
|
6
7
|
const googleCalendarClient_1 = require("./googleCalendarClient");
|
|
7
8
|
const SYSTEM = 'google-calendar';
|
|
8
9
|
const PROJECT_ENTITY = 'project';
|
|
9
10
|
const TASK_ENTITY = 'task';
|
|
10
11
|
async function syncTaskToGoogleCalendar(input, context) {
|
|
12
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
13
|
+
if (syncDirection === 'google-to-timesheet' || syncDirection === 'external-to-timesheet') {
|
|
14
|
+
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'sync-direction-mismatch' } };
|
|
15
|
+
}
|
|
11
16
|
const taskId = resolveTaskId(input);
|
|
12
17
|
if (!taskId) {
|
|
13
18
|
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-task-id' } };
|
|
@@ -53,7 +58,14 @@ async function syncTaskToGoogleCalendar(input, context) {
|
|
|
53
58
|
}
|
|
54
59
|
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'already-deleted' } };
|
|
55
60
|
}
|
|
56
|
-
|
|
61
|
+
let payload;
|
|
62
|
+
try {
|
|
63
|
+
payload = buildGoogleEventPayload(task);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
context.logger.warn('Failed to build event payload', { taskId: task.id, error: String(err) });
|
|
67
|
+
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'invalid-task-data', taskId: task.id } };
|
|
68
|
+
}
|
|
57
69
|
let externalEvent;
|
|
58
70
|
if (taskMapping?.externalId) {
|
|
59
71
|
const mappedCalendarId = getMappedCalendarId(taskMapping) ?? externalCalendarId;
|
|
@@ -97,26 +109,34 @@ async function syncTaskToGoogleCalendar(input, context) {
|
|
|
97
109
|
};
|
|
98
110
|
}
|
|
99
111
|
async function runGoogleCalendarFullSync(context) {
|
|
112
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
113
|
+
const allowInbound = syncDirection !== 'timesheet-to-google' && syncDirection !== 'timesheet-to-external';
|
|
100
114
|
const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
|
|
101
115
|
if (projectMappings.length === 0) {
|
|
102
116
|
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-project-mappings' } };
|
|
103
117
|
}
|
|
104
118
|
let syncedCount = 0;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
if (allowInbound) {
|
|
120
|
+
for (const mapping of projectMappings) {
|
|
121
|
+
if (!mapping.externalId) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const perCalendarCount = await syncCalendar(context, mapping);
|
|
125
|
+
syncedCount += perCalendarCount;
|
|
108
126
|
}
|
|
109
|
-
const perCalendarCount = await syncCalendar(context, mapping);
|
|
110
|
-
syncedCount += perCalendarCount;
|
|
111
127
|
}
|
|
112
128
|
return {
|
|
113
129
|
system: SYSTEM,
|
|
114
130
|
status: 'completed',
|
|
115
131
|
syncedCount,
|
|
116
|
-
details: { calendarCount: projectMappings.length }
|
|
132
|
+
details: { calendarCount: projectMappings.length, syncDirection }
|
|
117
133
|
};
|
|
118
134
|
}
|
|
119
135
|
async function handleGoogleWebhook(input, context) {
|
|
136
|
+
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
137
|
+
if (syncDirection === 'timesheet-to-google' || syncDirection === 'timesheet-to-external') {
|
|
138
|
+
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'sync-direction-mismatch' } };
|
|
139
|
+
}
|
|
120
140
|
const resourceState = getHeader(input, 'x-goog-resource-state')?.toLowerCase();
|
|
121
141
|
if (resourceState === 'sync') {
|
|
122
142
|
return {
|
|
@@ -279,13 +299,20 @@ async function syncSingleGoogleEvent(context, projectMapping, calendarId, event)
|
|
|
279
299
|
return true;
|
|
280
300
|
}
|
|
281
301
|
function toTaskDateRange(event) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
302
|
+
let startRaw = event.start?.dateTime;
|
|
303
|
+
let endRaw = event.end?.dateTime;
|
|
304
|
+
// Handle all-day events (date field instead of dateTime)
|
|
305
|
+
if (!startRaw && event.start?.date) {
|
|
306
|
+
startRaw = `${event.start.date}T00:00:00Z`;
|
|
307
|
+
}
|
|
308
|
+
if (!endRaw && event.end?.date) {
|
|
309
|
+
endRaw = `${event.end.date}T00:00:00Z`;
|
|
310
|
+
}
|
|
311
|
+
if (!startRaw || !endRaw) {
|
|
285
312
|
return null;
|
|
286
313
|
}
|
|
287
|
-
const start = new Date(
|
|
288
|
-
const end = new Date(
|
|
314
|
+
const start = new Date(startRaw);
|
|
315
|
+
const end = new Date(endRaw);
|
|
289
316
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
290
317
|
return null;
|
|
291
318
|
}
|
|
@@ -383,6 +410,78 @@ function getHeader(input, name) {
|
|
|
383
410
|
const value = mergedHeaders[key];
|
|
384
411
|
return value === undefined || value === null ? undefined : String(value);
|
|
385
412
|
}
|
|
413
|
+
async function ensureWatchChannels(context) {
|
|
414
|
+
// The webhook URL is passed via the context metadata (set by the backend runtime)
|
|
415
|
+
const contextAny = context;
|
|
416
|
+
const metadata = contextAny['metadata'];
|
|
417
|
+
const webhooks = metadata?.['webhooks'];
|
|
418
|
+
const webhookUrl = webhooks?.['integration-webhook'];
|
|
419
|
+
if (!webhookUrl) {
|
|
420
|
+
context.logger.info('No webhook URL available — skipping watch channel registration');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
|
|
424
|
+
if (projectMappings.length === 0) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const client = createClient(context);
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
const watchTtlSeconds = 7 * 24 * 60 * 60; // 7 days
|
|
430
|
+
for (const mapping of projectMappings) {
|
|
431
|
+
if (!mapping.externalId) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const metadata = mapping.metadata ?? {};
|
|
435
|
+
const existingExpiration = readMetadataString(metadata, 'watchExpiration');
|
|
436
|
+
if (existingExpiration) {
|
|
437
|
+
const expiresAt = Number(existingExpiration);
|
|
438
|
+
if (expiresAt > now + 3600000) {
|
|
439
|
+
// Watch still has > 1 hour remaining, skip
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
// Stop the old channel before creating a new one
|
|
443
|
+
const oldChannelId = readMetadataString(metadata, 'watchChannelId');
|
|
444
|
+
const oldResourceId = readMetadataString(metadata, 'watchResourceId');
|
|
445
|
+
if (oldChannelId && oldResourceId) {
|
|
446
|
+
try {
|
|
447
|
+
await client.stopWatch(oldChannelId, oldResourceId);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
context.logger.warn('Failed to stop old watch channel', { calendarId: mapping.externalId, error: String(err) });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const channelId = `ts-${context.installationId}-${mapping.externalId}-${now}`.substring(0, 64);
|
|
456
|
+
const watchResult = await client.watchEvents(mapping.externalId, channelId, webhookUrl, watchTtlSeconds);
|
|
457
|
+
await context.mappings.upsert({
|
|
458
|
+
system: SYSTEM,
|
|
459
|
+
entity: PROJECT_ENTITY,
|
|
460
|
+
localId: mapping.localId,
|
|
461
|
+
externalId: mapping.externalId,
|
|
462
|
+
externalLabel: mapping.externalLabel,
|
|
463
|
+
metadata: {
|
|
464
|
+
...metadata,
|
|
465
|
+
watchChannelId: channelId,
|
|
466
|
+
watchResourceId: watchResult.resourceId ?? '',
|
|
467
|
+
watchExpiration: watchResult.expiration ?? String(now + watchTtlSeconds * 1000)
|
|
468
|
+
},
|
|
469
|
+
syncStatus: 'SYNCED'
|
|
470
|
+
});
|
|
471
|
+
context.logger.info('Registered watch channel', {
|
|
472
|
+
calendarId: mapping.externalId,
|
|
473
|
+
channelId,
|
|
474
|
+
expiration: watchResult.expiration
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
context.logger.warn('Failed to register watch channel', {
|
|
479
|
+
calendarId: mapping.externalId,
|
|
480
|
+
error: String(err)
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
386
485
|
function readMetadataString(metadata, key) {
|
|
387
486
|
const value = metadata[key];
|
|
388
487
|
if (typeof value === 'string' && value.trim().length > 0) {
|
package/manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "google-calendar-sync",
|
|
3
3
|
"name": "Google Calendar Sync",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"description": "Synchronize Timesheet tasks with Google Calendar",
|
|
6
6
|
"longDescription": "Bidirectional synchronization between Timesheet tasks and Google Calendar events.",
|
|
7
7
|
"icon": "google-calendar",
|
|
8
8
|
"category": "calendar",
|
|
9
9
|
"tags": ["sync", "timesheet", "google-calendar", "calendar"],
|
|
10
|
-
"dataAccess": ["projects", "tasks"
|
|
10
|
+
"dataAccess": ["projects", "tasks"],
|
|
11
11
|
"externalAuth": [
|
|
12
12
|
{
|
|
13
13
|
"id": "google",
|