@shin1ohno/sage 0.7.8 → 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.
- package/README.md +26 -8
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +30 -1
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validation.d.ts +130 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +53 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/index.js +677 -214
- package/dist/index.js.map +1 -1
- package/dist/integrations/calendar-service.d.ts +6 -3
- package/dist/integrations/calendar-service.d.ts.map +1 -1
- package/dist/integrations/calendar-service.js +26 -4
- package/dist/integrations/calendar-service.js.map +1 -1
- package/dist/integrations/calendar-source-manager.d.ts +302 -0
- package/dist/integrations/calendar-source-manager.d.ts.map +1 -0
- package/dist/integrations/calendar-source-manager.js +862 -0
- package/dist/integrations/calendar-source-manager.js.map +1 -0
- package/dist/integrations/google-calendar-service.d.ts +176 -0
- package/dist/integrations/google-calendar-service.d.ts.map +1 -0
- package/dist/integrations/google-calendar-service.js +745 -0
- package/dist/integrations/google-calendar-service.js.map +1 -0
- package/dist/oauth/google-oauth-handler.d.ts +149 -0
- package/dist/oauth/google-oauth-handler.d.ts.map +1 -0
- package/dist/oauth/google-oauth-handler.js +365 -0
- package/dist/oauth/google-oauth-handler.js.map +1 -0
- package/dist/types/config.d.ts +15 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +21 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/google-calendar-types.d.ts +139 -0
- package/dist/types/google-calendar-types.d.ts.map +1 -0
- package/dist/types/google-calendar-types.js +46 -0
- package/dist/types/google-calendar-types.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +22 -4
- package/dist/version.js.map +1 -1
- 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
|