@shin1ohno/sage 0.7.9 → 0.8.4

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.
Files changed (43) hide show
  1. package/README.md +26 -8
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +30 -1
  4. package/dist/config/loader.js.map +1 -1
  5. package/dist/config/validation.d.ts +130 -0
  6. package/dist/config/validation.d.ts.map +1 -0
  7. package/dist/config/validation.js +53 -0
  8. package/dist/config/validation.js.map +1 -0
  9. package/dist/index.js +677 -214
  10. package/dist/index.js.map +1 -1
  11. package/dist/integrations/calendar-service.d.ts +6 -3
  12. package/dist/integrations/calendar-service.d.ts.map +1 -1
  13. package/dist/integrations/calendar-service.js +26 -4
  14. package/dist/integrations/calendar-service.js.map +1 -1
  15. package/dist/integrations/calendar-source-manager.d.ts +302 -0
  16. package/dist/integrations/calendar-source-manager.d.ts.map +1 -0
  17. package/dist/integrations/calendar-source-manager.js +862 -0
  18. package/dist/integrations/calendar-source-manager.js.map +1 -0
  19. package/dist/integrations/google-calendar-service.d.ts +176 -0
  20. package/dist/integrations/google-calendar-service.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar-service.js +745 -0
  22. package/dist/integrations/google-calendar-service.js.map +1 -0
  23. package/dist/oauth/google-oauth-handler.d.ts +149 -0
  24. package/dist/oauth/google-oauth-handler.d.ts.map +1 -0
  25. package/dist/oauth/google-oauth-handler.js +365 -0
  26. package/dist/oauth/google-oauth-handler.js.map +1 -0
  27. package/dist/types/config.d.ts +15 -0
  28. package/dist/types/config.d.ts.map +1 -1
  29. package/dist/types/config.js +21 -0
  30. package/dist/types/config.js.map +1 -1
  31. package/dist/types/google-calendar-types.d.ts +139 -0
  32. package/dist/types/google-calendar-types.d.ts.map +1 -0
  33. package/dist/types/google-calendar-types.js +46 -0
  34. package/dist/types/google-calendar-types.js.map +1 -0
  35. package/dist/types/index.d.ts +1 -0
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/types/index.js +1 -0
  38. package/dist/types/index.js.map +1 -1
  39. package/dist/version.d.ts +2 -2
  40. package/dist/version.d.ts.map +1 -1
  41. package/dist/version.js +22 -4
  42. package/dist/version.js.map +1 -1
  43. package/package.json +4 -3
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Google Calendar Service
3
+ * Requirements: 1, 10 (Google Calendar OAuth Authentication, Health Check)
4
+ *
5
+ * Provides Google Calendar API integration with OAuth authentication,
6
+ * event CRUD operations, and calendar management.
7
+ */
8
+ import { google } from 'googleapis';
9
+ import { convertGoogleToCalendarEvent } from '../types/google-calendar-types.js';
10
+ import { retryWithBackoff } from '../utils/retry.js';
11
+ /**
12
+ * Google Calendar Service Class
13
+ *
14
+ * Manages Google Calendar API integration with OAuth authentication.
15
+ * Provides methods for event CRUD operations, calendar management, and health checks.
16
+ */
17
+ export class GoogleCalendarService {
18
+ oauthHandler;
19
+ calendarClient = null;
20
+ /**
21
+ * Constructor
22
+ *
23
+ * @param oauthHandler - GoogleOAuthHandler instance for authentication
24
+ * @param config - Optional configuration (userId, etc.)
25
+ */
26
+ constructor(oauthHandler, _config) {
27
+ this.oauthHandler = oauthHandler;
28
+ }
29
+ /**
30
+ * Authenticate and initialize Google Calendar client
31
+ *
32
+ * Calls GoogleOAuthHandler.ensureValidToken() to get a valid access token,
33
+ * then initializes the google.calendar() client with the OAuth2Client.
34
+ *
35
+ * @throws Error if authentication fails or no stored tokens found
36
+ */
37
+ async authenticate() {
38
+ try {
39
+ // Get valid access token (refreshes if expired)
40
+ await this.oauthHandler.ensureValidToken();
41
+ // Get stored tokens for OAuth2Client configuration
42
+ const tokens = await this.oauthHandler.getTokens();
43
+ if (!tokens) {
44
+ throw new Error('No stored tokens found after ensureValidToken()');
45
+ }
46
+ // Get OAuth2Client instance
47
+ const oauth2Client = this.oauthHandler.getOAuth2Client(tokens);
48
+ // Initialize Google Calendar API client
49
+ this.calendarClient = google.calendar({
50
+ version: 'v3',
51
+ auth: oauth2Client,
52
+ });
53
+ }
54
+ catch (error) {
55
+ throw new Error(`Failed to authenticate with Google Calendar: ${error instanceof Error ? error.message : 'Unknown error'}`);
56
+ }
57
+ }
58
+ /**
59
+ * Check if Google Calendar API is available
60
+ *
61
+ * Attempts a simple Calendar API call (calendarList.list with maxResults=1)
62
+ * to verify authentication and API availability.
63
+ *
64
+ * @returns True if API is available and authenticated, false otherwise
65
+ */
66
+ async isAvailable() {
67
+ try {
68
+ // Ensure client is authenticated
69
+ if (!this.calendarClient) {
70
+ await this.authenticate();
71
+ }
72
+ // Try a simple API call to verify availability
73
+ await this.calendarClient.calendarList.list({
74
+ maxResults: 1,
75
+ });
76
+ return true;
77
+ }
78
+ catch (error) {
79
+ // Suppress errors and return false
80
+ // (Health check should not throw errors)
81
+ return false;
82
+ }
83
+ }
84
+ /**
85
+ * List calendar events with pagination support
86
+ * Requirement: 2, 10 (Google Calendar event retrieval, retry with backoff)
87
+ *
88
+ * Fetches all events within the specified date range using automatic pagination.
89
+ * Collects events from all pages until no more pageToken is returned.
90
+ * Includes retry logic with exponential backoff for transient failures.
91
+ * Expands recurring events into individual instances.
92
+ *
93
+ * @param request - ListEventsRequest with date range and optional calendar ID
94
+ * @returns Array of unified CalendarEvent objects
95
+ * @throws Error if authentication fails or API request fails after retries
96
+ */
97
+ async listEvents(request) {
98
+ // Ensure client is authenticated
99
+ if (!this.calendarClient) {
100
+ await this.authenticate();
101
+ }
102
+ const calendarId = request.calendarId || 'primary';
103
+ const allEvents = [];
104
+ let pageToken = undefined;
105
+ try {
106
+ // Fetch all pages until no more pageToken
107
+ do {
108
+ const response = await retryWithBackoff(async () => {
109
+ return (await this.calendarClient.events.list({
110
+ calendarId: calendarId,
111
+ timeMin: request.startDate,
112
+ timeMax: request.endDate,
113
+ maxResults: 250,
114
+ pageToken: pageToken,
115
+ singleEvents: true, // Expand recurring events into individual instances
116
+ })).data;
117
+ }, {
118
+ maxAttempts: 3,
119
+ initialDelay: 1000,
120
+ shouldRetry: (error) => {
121
+ // Retry on rate limit (429) and server errors (500, 503)
122
+ const message = error.message.toLowerCase();
123
+ if (message.includes('rate limit') ||
124
+ message.includes('429') ||
125
+ message.includes('500') ||
126
+ message.includes('503') ||
127
+ message.includes('service unavailable') ||
128
+ message.includes('temporary')) {
129
+ return true;
130
+ }
131
+ // Don't retry on auth errors (401, 403)
132
+ if (message.includes('unauthorized') ||
133
+ message.includes('401') ||
134
+ message.includes('forbidden') ||
135
+ message.includes('403')) {
136
+ return false;
137
+ }
138
+ // Default to retryable
139
+ return true;
140
+ },
141
+ });
142
+ // Collect events from current page
143
+ const events = response.items || [];
144
+ allEvents.push(...events);
145
+ // Get next page token (if any)
146
+ pageToken = response.nextPageToken || undefined;
147
+ } while (pageToken);
148
+ // Convert GoogleCalendarEvent[] to CalendarEvent[]
149
+ return allEvents.map(event => convertGoogleToCalendarEvent(event));
150
+ }
151
+ catch (error) {
152
+ throw new Error(`Failed to list events from Google Calendar: ${error instanceof Error ? error.message : 'Unknown error'}`);
153
+ }
154
+ }
155
+ /**
156
+ * Create a calendar event
157
+ * Requirement: 3, 10 (Calendar event creation with retry logic)
158
+ *
159
+ * Creates a new event in Google Calendar with support for all-day events,
160
+ * reminders, and attendees. Includes retry logic with exponential backoff
161
+ * for transient failures.
162
+ *
163
+ * @param request - CreateEventRequest with event details
164
+ * @param calendarId - Calendar ID (optional, defaults to 'primary')
165
+ * @returns Created calendar event
166
+ * @throws Error if authentication fails or API request fails after retries
167
+ */
168
+ async createEvent(request, calendarId) {
169
+ // Ensure client is authenticated
170
+ if (!this.calendarClient) {
171
+ await this.authenticate();
172
+ }
173
+ const targetCalendarId = calendarId || 'primary';
174
+ try {
175
+ // Build Google Calendar event object
176
+ const eventBody = {
177
+ summary: request.title,
178
+ location: request.location,
179
+ description: request.description,
180
+ };
181
+ // Handle all-day vs timed events
182
+ if (request.isAllDay) {
183
+ // All-day events use 'date' field (YYYY-MM-DD format)
184
+ eventBody.start = {
185
+ date: request.start.split('T')[0], // Extract date part from ISO 8601
186
+ };
187
+ eventBody.end = {
188
+ date: request.end.split('T')[0],
189
+ };
190
+ }
191
+ else {
192
+ // Timed events use 'dateTime' field (ISO 8601 with timezone)
193
+ eventBody.start = {
194
+ dateTime: request.start,
195
+ };
196
+ eventBody.end = {
197
+ dateTime: request.end,
198
+ };
199
+ }
200
+ // Handle attendees
201
+ if (request.attendees && request.attendees.length > 0) {
202
+ eventBody.attendees = request.attendees.map(email => ({ email }));
203
+ }
204
+ // Handle reminders
205
+ if (request.reminders) {
206
+ eventBody.reminders = request.reminders;
207
+ }
208
+ // Create event with retry logic
209
+ const response = await retryWithBackoff(async () => {
210
+ return (await this.calendarClient.events.insert({
211
+ calendarId: targetCalendarId,
212
+ requestBody: eventBody,
213
+ sendUpdates: request.attendees && request.attendees.length > 0 ? 'all' : 'none',
214
+ })).data;
215
+ }, {
216
+ maxAttempts: 3,
217
+ initialDelay: 1000,
218
+ shouldRetry: (error) => {
219
+ // Retry on rate limit (429) and server errors (500, 503)
220
+ const message = error.message.toLowerCase();
221
+ if (message.includes('rate limit') ||
222
+ message.includes('429') ||
223
+ message.includes('500') ||
224
+ message.includes('503') ||
225
+ message.includes('service unavailable') ||
226
+ message.includes('temporary')) {
227
+ return true;
228
+ }
229
+ // Don't retry on auth errors (401, 403)
230
+ if (message.includes('unauthorized') ||
231
+ message.includes('401') ||
232
+ message.includes('forbidden') ||
233
+ message.includes('403')) {
234
+ return false;
235
+ }
236
+ // Default to retryable
237
+ return true;
238
+ },
239
+ });
240
+ // Convert created event to CalendarEvent
241
+ return convertGoogleToCalendarEvent(response);
242
+ }
243
+ catch (error) {
244
+ throw new Error(`Failed to create event in Google Calendar: ${error instanceof Error ? error.message : 'Unknown error'}`);
245
+ }
246
+ }
247
+ /**
248
+ * Update a calendar event
249
+ * Requirement: 4, 10 (Calendar event updates with partial updates and retry logic)
250
+ *
251
+ * Updates an existing event in Google Calendar using the patch API for partial updates.
252
+ * Supports all-day events, reminders, attendees, and recurring events.
253
+ * Includes retry logic with exponential backoff for transient failures.
254
+ *
255
+ * @param eventId - Event ID to update
256
+ * @param updates - Partial CreateEventRequest with fields to update
257
+ * @param calendarId - Calendar ID (optional, defaults to 'primary')
258
+ * @returns Updated calendar event
259
+ * @throws Error if authentication fails or API request fails after retries
260
+ */
261
+ async updateEvent(eventId, updates, calendarId) {
262
+ // Ensure client is authenticated
263
+ if (!this.calendarClient) {
264
+ await this.authenticate();
265
+ }
266
+ const targetCalendarId = calendarId || 'primary';
267
+ try {
268
+ // Build Google Calendar patch object (only include provided fields)
269
+ const patchBody = {};
270
+ // Handle title
271
+ if (updates.title !== undefined) {
272
+ patchBody.summary = updates.title;
273
+ }
274
+ // Handle location
275
+ if (updates.location !== undefined) {
276
+ patchBody.location = updates.location;
277
+ }
278
+ // Handle description
279
+ if (updates.description !== undefined) {
280
+ patchBody.description = updates.description;
281
+ }
282
+ // Handle date/time updates
283
+ if (updates.start !== undefined || updates.end !== undefined || updates.isAllDay !== undefined) {
284
+ // If updating dates, need to handle both start and end consistently
285
+ if (updates.isAllDay !== undefined && updates.isAllDay) {
286
+ // All-day event: use 'date' field (YYYY-MM-DD format)
287
+ if (updates.start !== undefined) {
288
+ patchBody.start = {
289
+ date: updates.start.split('T')[0], // Extract date part from ISO 8601
290
+ };
291
+ }
292
+ if (updates.end !== undefined) {
293
+ patchBody.end = {
294
+ date: updates.end.split('T')[0],
295
+ };
296
+ }
297
+ }
298
+ else if (updates.isAllDay !== undefined && !updates.isAllDay) {
299
+ // Timed event: use 'dateTime' field (ISO 8601 with timezone)
300
+ if (updates.start !== undefined) {
301
+ patchBody.start = {
302
+ dateTime: updates.start,
303
+ };
304
+ }
305
+ if (updates.end !== undefined) {
306
+ patchBody.end = {
307
+ dateTime: updates.end,
308
+ };
309
+ }
310
+ }
311
+ else {
312
+ // isAllDay not specified, infer from existing format or default to dateTime
313
+ if (updates.start !== undefined) {
314
+ patchBody.start = {
315
+ dateTime: updates.start,
316
+ };
317
+ }
318
+ if (updates.end !== undefined) {
319
+ patchBody.end = {
320
+ dateTime: updates.end,
321
+ };
322
+ }
323
+ }
324
+ }
325
+ // Handle attendees
326
+ if (updates.attendees !== undefined) {
327
+ if (updates.attendees.length > 0) {
328
+ patchBody.attendees = updates.attendees.map(email => ({ email }));
329
+ }
330
+ else {
331
+ // Empty array means remove all attendees
332
+ patchBody.attendees = [];
333
+ }
334
+ }
335
+ // Handle reminders
336
+ if (updates.reminders !== undefined) {
337
+ patchBody.reminders = updates.reminders;
338
+ }
339
+ // Update event with retry logic using patch API
340
+ const response = await retryWithBackoff(async () => {
341
+ return (await this.calendarClient.events.patch({
342
+ calendarId: targetCalendarId,
343
+ eventId: eventId,
344
+ requestBody: patchBody,
345
+ sendUpdates: updates.attendees !== undefined ? 'all' : 'none',
346
+ })).data;
347
+ }, {
348
+ maxAttempts: 3,
349
+ initialDelay: 1000,
350
+ shouldRetry: (error) => {
351
+ // Retry on rate limit (429) and server errors (500, 503)
352
+ const message = error.message.toLowerCase();
353
+ if (message.includes('rate limit') ||
354
+ message.includes('429') ||
355
+ message.includes('500') ||
356
+ message.includes('503') ||
357
+ message.includes('service unavailable') ||
358
+ message.includes('temporary')) {
359
+ return true;
360
+ }
361
+ // Don't retry on auth errors (401, 403)
362
+ if (message.includes('unauthorized') ||
363
+ message.includes('401') ||
364
+ message.includes('forbidden') ||
365
+ message.includes('403')) {
366
+ return false;
367
+ }
368
+ // Don't retry on not found errors (404)
369
+ if (message.includes('not found') ||
370
+ message.includes('404')) {
371
+ return false;
372
+ }
373
+ // Default to retryable
374
+ return true;
375
+ },
376
+ });
377
+ // Convert updated event to CalendarEvent
378
+ return convertGoogleToCalendarEvent(response);
379
+ }
380
+ catch (error) {
381
+ throw new Error(`Failed to update event in Google Calendar: ${error instanceof Error ? error.message : 'Unknown error'}`);
382
+ }
383
+ }
384
+ /**
385
+ * Delete a calendar event
386
+ * Requirement: 5, 10 (Calendar event deletion with retry logic)
387
+ *
388
+ * Deletes a single event from Google Calendar using the delete API.
389
+ * Includes retry logic with exponential backoff for transient failures.
390
+ * Handles 404 errors gracefully (event already deleted).
391
+ *
392
+ * @param eventId - Event ID to delete
393
+ * @param calendarId - Calendar ID (optional, defaults to 'primary')
394
+ * @throws Error if authentication fails or API request fails after retries (except 404)
395
+ */
396
+ async deleteEvent(eventId, calendarId) {
397
+ // Ensure client is authenticated
398
+ if (!this.calendarClient) {
399
+ await this.authenticate();
400
+ }
401
+ const targetCalendarId = calendarId || 'primary';
402
+ try {
403
+ // Delete event with retry logic
404
+ await retryWithBackoff(async () => {
405
+ await this.calendarClient.events.delete({
406
+ calendarId: targetCalendarId,
407
+ eventId: eventId,
408
+ });
409
+ }, {
410
+ maxAttempts: 3,
411
+ initialDelay: 1000,
412
+ shouldRetry: (error) => {
413
+ const message = error.message.toLowerCase();
414
+ // Don't retry on 404 (event not found / already deleted)
415
+ if (message.includes('not found') ||
416
+ message.includes('404')) {
417
+ return false;
418
+ }
419
+ // Don't retry on auth errors (401, 403)
420
+ if (message.includes('unauthorized') ||
421
+ message.includes('401') ||
422
+ message.includes('forbidden') ||
423
+ message.includes('403')) {
424
+ return false;
425
+ }
426
+ // Retry on rate limit (429) and server errors (500, 503)
427
+ if (message.includes('rate limit') ||
428
+ message.includes('429') ||
429
+ message.includes('500') ||
430
+ message.includes('503') ||
431
+ message.includes('service unavailable') ||
432
+ message.includes('temporary')) {
433
+ return true;
434
+ }
435
+ // Default to retryable
436
+ return true;
437
+ },
438
+ });
439
+ }
440
+ catch (error) {
441
+ // Handle 404 gracefully (event already deleted)
442
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
443
+ if (message.includes('not found') || message.includes('404')) {
444
+ // Silently succeed if event doesn't exist
445
+ return;
446
+ }
447
+ // Throw other errors
448
+ throw new Error(`Failed to delete event from Google Calendar: ${error instanceof Error ? error.message : 'Unknown error'}`);
449
+ }
450
+ }
451
+ /**
452
+ * Delete multiple calendar events in batch
453
+ * Requirement: 5, 10 (Calendar event batch deletion with retry logic)
454
+ *
455
+ * Deletes multiple events from Google Calendar using batch API requests.
456
+ * Splits eventIds into chunks of 50 (Google API limit) if needed.
457
+ * Includes retry logic with exponential backoff for transient failures.
458
+ * Handles partial failures gracefully (continues with remaining chunks).
459
+ *
460
+ * @param eventIds - Array of event IDs to delete
461
+ * @param calendarId - Calendar ID (optional, defaults to 'primary')
462
+ * @returns Object with count of successfully deleted events
463
+ */
464
+ async deleteEventsBatch(eventIds, calendarId) {
465
+ // Ensure client is authenticated
466
+ if (!this.calendarClient) {
467
+ await this.authenticate();
468
+ }
469
+ const targetCalendarId = calendarId || 'primary';
470
+ let totalDeleted = 0;
471
+ // Split into chunks of 50 (Google Batch API limit)
472
+ const chunks = [];
473
+ for (let i = 0; i < eventIds.length; i += 50) {
474
+ chunks.push(eventIds.slice(i, i + 50));
475
+ }
476
+ // Process each chunk
477
+ for (const chunk of chunks) {
478
+ try {
479
+ await retryWithBackoff(async () => {
480
+ // Create individual delete promises for this chunk
481
+ const deletePromises = chunk.map(eventId => this.calendarClient.events.delete({
482
+ calendarId: targetCalendarId,
483
+ eventId: eventId,
484
+ }).catch(error => {
485
+ // Handle 404 gracefully (event already deleted)
486
+ const message = error.message ? error.message.toLowerCase() : '';
487
+ if (message.includes('not found') || message.includes('404')) {
488
+ // Count as successful if event doesn't exist
489
+ return { success: true };
490
+ }
491
+ throw error;
492
+ }));
493
+ // Execute all deletes in parallel for this chunk
494
+ await Promise.all(deletePromises);
495
+ }, {
496
+ maxAttempts: 3,
497
+ initialDelay: 1000,
498
+ shouldRetry: (error) => {
499
+ const message = error.message.toLowerCase();
500
+ // Don't retry on auth errors (401, 403)
501
+ if (message.includes('unauthorized') ||
502
+ message.includes('401') ||
503
+ message.includes('forbidden') ||
504
+ message.includes('403')) {
505
+ return false;
506
+ }
507
+ // Retry on rate limit (429) and server errors (500, 503)
508
+ if (message.includes('rate limit') ||
509
+ message.includes('429') ||
510
+ message.includes('500') ||
511
+ message.includes('503') ||
512
+ message.includes('service unavailable') ||
513
+ message.includes('temporary')) {
514
+ return true;
515
+ }
516
+ // Default to retryable
517
+ return true;
518
+ },
519
+ });
520
+ // Count successful deletions for this chunk
521
+ totalDeleted += chunk.length;
522
+ }
523
+ catch (error) {
524
+ // Log error but continue with remaining chunks
525
+ // This ensures partial success if some chunks fail
526
+ console.error(`Failed to delete chunk of events: ${error instanceof Error ? error.message : 'Unknown error'}`);
527
+ }
528
+ }
529
+ return { deleted: totalDeleted };
530
+ }
531
+ /**
532
+ * Respond to a calendar event invitation
533
+ * Requirement: 6, 10 (Event invitation response with retry logic)
534
+ *
535
+ * Updates the current user's attendance status for an event invitation.
536
+ * Sends notification to the organizer with sendUpdates='all'.
537
+ * Includes retry logic with exponential backoff for transient failures.
538
+ *
539
+ * @param eventId - Event ID to respond to
540
+ * @param response - Response status ('accepted' | 'declined' | 'tentative')
541
+ * @param calendarId - Calendar ID (optional, defaults to 'primary')
542
+ * @throws Error if authentication fails, event not found, or user is not an attendee
543
+ */
544
+ async respondToEvent(eventId, response, calendarId) {
545
+ // Ensure client is authenticated
546
+ if (!this.calendarClient) {
547
+ await this.authenticate();
548
+ }
549
+ const targetCalendarId = calendarId || 'primary';
550
+ try {
551
+ // Step 1: Get current user's email by fetching primary calendar info
552
+ const userEmail = await retryWithBackoff(async () => {
553
+ const calendarInfo = await this.calendarClient.calendarList.get({
554
+ calendarId: 'primary',
555
+ });
556
+ return calendarInfo.data.id;
557
+ }, {
558
+ maxAttempts: 3,
559
+ initialDelay: 1000,
560
+ shouldRetry: (error) => {
561
+ const message = error.message.toLowerCase();
562
+ if (message.includes('rate limit') ||
563
+ message.includes('429') ||
564
+ message.includes('500') ||
565
+ message.includes('503') ||
566
+ message.includes('service unavailable') ||
567
+ message.includes('temporary')) {
568
+ return true;
569
+ }
570
+ if (message.includes('unauthorized') ||
571
+ message.includes('401') ||
572
+ message.includes('forbidden') ||
573
+ message.includes('403')) {
574
+ return false;
575
+ }
576
+ return true;
577
+ },
578
+ });
579
+ if (!userEmail) {
580
+ throw new Error('Failed to retrieve user email from Google Calendar');
581
+ }
582
+ // Step 2: Get current event with retry
583
+ const event = await retryWithBackoff(async () => {
584
+ return (await this.calendarClient.events.get({
585
+ calendarId: targetCalendarId,
586
+ eventId: eventId,
587
+ })).data;
588
+ }, {
589
+ maxAttempts: 3,
590
+ initialDelay: 1000,
591
+ shouldRetry: (error) => {
592
+ const message = error.message.toLowerCase();
593
+ if (message.includes('not found') ||
594
+ message.includes('404')) {
595
+ return false;
596
+ }
597
+ if (message.includes('unauthorized') ||
598
+ message.includes('401') ||
599
+ message.includes('forbidden') ||
600
+ message.includes('403')) {
601
+ return false;
602
+ }
603
+ if (message.includes('rate limit') ||
604
+ message.includes('429') ||
605
+ message.includes('500') ||
606
+ message.includes('503') ||
607
+ message.includes('service unavailable') ||
608
+ message.includes('temporary')) {
609
+ return true;
610
+ }
611
+ return true;
612
+ },
613
+ });
614
+ // Step 3: Validate event and user
615
+ if (!event.attendees || event.attendees.length === 0) {
616
+ throw new Error('Event has no attendees. Cannot respond to this event.');
617
+ }
618
+ // Check if user is the organizer
619
+ if (event.organizer && event.organizer.email === userEmail) {
620
+ throw new Error('Cannot respond to event as the organizer.');
621
+ }
622
+ // Step 4: Find current user in attendees list
623
+ const userAttendee = event.attendees.find(attendee => attendee.email === userEmail);
624
+ if (!userAttendee) {
625
+ throw new Error(`User ${userEmail} is not an attendee of this event.`);
626
+ }
627
+ // Step 5: Update attendee's response status
628
+ const updatedAttendees = event.attendees.map(attendee => {
629
+ if (attendee.email === userEmail) {
630
+ return {
631
+ ...attendee,
632
+ responseStatus: response,
633
+ };
634
+ }
635
+ return attendee;
636
+ });
637
+ // Step 6: Patch event with updated attendees and retry
638
+ await retryWithBackoff(async () => {
639
+ await this.calendarClient.events.patch({
640
+ calendarId: targetCalendarId,
641
+ eventId: eventId,
642
+ requestBody: {
643
+ attendees: updatedAttendees,
644
+ },
645
+ sendUpdates: 'all', // Notify organizer and other attendees
646
+ });
647
+ }, {
648
+ maxAttempts: 3,
649
+ initialDelay: 1000,
650
+ shouldRetry: (error) => {
651
+ const message = error.message.toLowerCase();
652
+ if (message.includes('not found') ||
653
+ message.includes('404')) {
654
+ return false;
655
+ }
656
+ if (message.includes('unauthorized') ||
657
+ message.includes('401') ||
658
+ message.includes('forbidden') ||
659
+ message.includes('403')) {
660
+ return false;
661
+ }
662
+ if (message.includes('rate limit') ||
663
+ message.includes('429') ||
664
+ message.includes('500') ||
665
+ message.includes('503') ||
666
+ message.includes('service unavailable') ||
667
+ message.includes('temporary')) {
668
+ return true;
669
+ }
670
+ return true;
671
+ },
672
+ });
673
+ }
674
+ catch (error) {
675
+ const message = error instanceof Error ? error.message : 'Unknown error';
676
+ if (message.includes('not found') || message.includes('404')) {
677
+ throw new Error(`Event with ID ${eventId} not found in Google Calendar.`);
678
+ }
679
+ throw new Error(`Failed to respond to event in Google Calendar: ${message}`);
680
+ }
681
+ }
682
+ /**
683
+ * List all calendars
684
+ * Requirement: 2, 9 (Calendar list retrieval, User data privacy)
685
+ *
686
+ * Fetches all calendars accessible by the user from Google Calendar API.
687
+ * Returns CalendarInfo array with id, name, isPrimary, accessRole, and color.
688
+ * Includes retry logic with exponential backoff for transient failures.
689
+ *
690
+ * @returns Array of CalendarInfo objects
691
+ * @throws Error if authentication fails or API request fails after retries
692
+ */
693
+ async listCalendars() {
694
+ // Ensure client is authenticated
695
+ if (!this.calendarClient) {
696
+ await this.authenticate();
697
+ }
698
+ try {
699
+ // Fetch calendar list with retry logic
700
+ const response = await retryWithBackoff(async () => {
701
+ return (await this.calendarClient.calendarList.list({
702
+ showHidden: true, // Include hidden calendars
703
+ })).data;
704
+ }, {
705
+ maxAttempts: 3,
706
+ initialDelay: 1000,
707
+ shouldRetry: (error) => {
708
+ // Retry on rate limit (429) and server errors (500, 503)
709
+ const message = error.message.toLowerCase();
710
+ if (message.includes('rate limit') ||
711
+ message.includes('429') ||
712
+ message.includes('500') ||
713
+ message.includes('503') ||
714
+ message.includes('service unavailable') ||
715
+ message.includes('temporary')) {
716
+ return true;
717
+ }
718
+ // Don't retry on auth errors (401, 403)
719
+ if (message.includes('unauthorized') ||
720
+ message.includes('401') ||
721
+ message.includes('forbidden') ||
722
+ message.includes('403')) {
723
+ return false;
724
+ }
725
+ // Default to retryable
726
+ return true;
727
+ },
728
+ });
729
+ // Convert Google Calendar items to CalendarInfo format
730
+ const calendars = response.items || [];
731
+ return calendars.map(calendar => ({
732
+ id: calendar.id || '',
733
+ name: calendar.summary || '',
734
+ source: 'google',
735
+ isPrimary: calendar.primary || false,
736
+ color: calendar.backgroundColor || undefined,
737
+ accessRole: calendar.accessRole,
738
+ }));
739
+ }
740
+ catch (error) {
741
+ throw new Error(`Failed to list calendars from Google Calendar: ${error instanceof Error ? error.message : 'Unknown error'}`);
742
+ }
743
+ }
744
+ }
745
+ //# sourceMappingURL=google-calendar-service.js.map