@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
- return await (0, taskSync_1.runGoogleCalendarFullSync)(context);
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), {
@@ -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>;
@@ -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
- const payload = buildGoogleEventPayload(task);
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
- for (const mapping of projectMappings) {
106
- if (!mapping.externalId) {
107
- continue;
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
- const startDateTime = event.start?.dateTime;
283
- const endDateTime = event.end?.dateTime;
284
- if (!startDateTime || !endDateTime) {
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(startDateTime);
288
- const end = new Date(endDateTime);
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.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", "colleagues", "settings"],
10
+ "dataAccess": ["projects", "tasks"],
11
11
  "externalAuth": [
12
12
  {
13
13
  "id": "google",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timesheet/plugin-google-calendar",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [