diff --git a/.env.example b/.env.example index 38300f6..a94b4e1 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ GDRIVE_TOKEN_RETRY_DELAY=1000 # Initial retry delay (in milliseconds) # GDRIVE_CREDENTIALS_PATH=~/.gdrive-server-credentials.json # GDRIVE_OAUTH_PATH=./gcp-oauth.keys.json +# Optional: PAI contact resolution for Calendar module +# Set this to enable resolving contact names (e.g., "Mary") to email addresses +# Without this, all attendee inputs are treated as raw email addresses +# PAI_CONTACTS_PATH=/path/to/your/CONTACTS.md + # Optional: Logging configuration LOG_LEVEL=info # Options: error, warn, info, debug, verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c33239..7fd97f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.0] - 2026-01-08 + +### โœจ New Features + +#### Google Calendar API Integration +Added complete Google Calendar functionality with 9 operations following the established operation-based architecture pattern. + +**New Calendar Operations:** +- `listCalendars` - List all calendars with access role and time zone +- `getCalendar` - Get calendar details by ID +- `listEvents` - List events with time range filters and pagination +- `getEvent` - Get full event details including attendees and recurrence +- `createEvent` - Create events with attendees, recurrence, Google Meet, reminders +- `updateEvent` - Partial updates to existing events +- `deleteEvent` - Delete events with attendee notification options +- `quickAdd` - Create events from natural language strings +- `checkFreeBusy` - Query availability across multiple calendars + +**PAI Contact Resolution:** +- Resolve contact names (e.g., "Mary") to email addresses automatically +- Supports mixed inputs: `["Mary", "user@example.com"]` +- Case-insensitive matching against PAI contact list + +**New OAuth Scopes Required:** +```text +calendar.readonly - Read calendars and events +calendar.events - Create, update, delete events +``` + +โš ๏ธ **Re-authentication required** - Users must re-authenticate after upgrading to grant Calendar permissions. + +**New Files:** +- `src/modules/calendar/` - Complete Calendar module with 9 files +- `src/modules/calendar/types.ts` - TypeScript interfaces +- `src/modules/calendar/contacts.ts` - PAI contact resolution +- `src/modules/calendar/list.ts` - listCalendars, listEvents +- `src/modules/calendar/read.ts` - getCalendar, getEvent +- `src/modules/calendar/create.ts` - createEvent, quickAdd +- `src/modules/calendar/update.ts` - updateEvent +- `src/modules/calendar/delete.ts` - deleteEvent +- `src/modules/calendar/freebusy.ts` - checkFreeBusy +- `src/modules/calendar/index.ts` - Public exports + +### ๐Ÿงช Testing + +- Added 59 unit tests across 4 test suites +- Tests cover contacts, list, read, and freebusy operations +- Full caching and performance monitoring coverage + +### ๐Ÿ—๏ธ Internal + +- Added `CalendarContext` type extending `BaseContext` +- Calendar module follows Gmail module patterns exactly +- Full Redis caching support (5-minute TTL for reads, 60s for freebusy) +- Performance monitoring integrated for all operations +- Cache invalidation on write operations (create, update, delete) + +--- + ## [3.2.0] - 2025-12-23 ### โœจ New Features diff --git a/CLAUDE.md b/CLAUDE.md index ec2853a..35de785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ This is a Model Context Protocol (MCP) server for Google Drive integration. It p - Google Forms creation and management with question types - **Google Docs API integration** - Create documents, insert text, replace text, apply formatting, insert tables - **Gmail API integration** - Read, search, compose, send emails, manage labels (v3.2.0+) +- **Google Calendar API integration** - List calendars, manage events, check availability, quick add with natural language (v3.3.0+) - **Batch file operations** - Process multiple files in a single operation (create, update, delete, move) - Enhanced search with natural language parsing - Forms response handling and analysis @@ -62,6 +63,23 @@ This is a Model Context Protocol (MCP) server for Google Drive integration. It p - Docker support for containerized deployment with Redis - **BMAD Framework Integration** - Agent-driven development methodology for structured brownfield and greenfield projects +## Git Workflow + +**IMPORTANT: Main branch is protected.** All changes must go through pull requests. + +```bash +# Create feature branch +git checkout -b feature/your-feature-name + +# Push and create PR +git push -u origin feature/your-feature-name +gh pr create --title "feat: Your feature" --body "Description" +``` + +- Direct pushes to `main` will be rejected +- PRs require review before merging +- Use conventional commit messages (feat:, fix:, docs:, etc.) + ## Key Commands ### Build & Development @@ -91,6 +109,7 @@ This is a Model Context Protocol (MCP) server for Google Drive integration. It p - **Forms API Integration** - Google Forms v1 API for form creation and management - **Docs API Integration** - Google Docs v1 API for document manipulation - **Gmail API Integration** - Gmail v1 API for email operations (v3.2.0+) +- **Calendar API Integration** - Google Calendar v3 API for calendar and event management (v3.3.0+) - **Redis Cache Manager** - High-performance caching with automatic invalidation - **Performance Monitor** - Real-time performance tracking and statistics - **Winston Logger** - Structured logging with file rotation and console output @@ -104,6 +123,7 @@ This is a Model Context Protocol (MCP) server for Google Drive integration. It p - **Forms Operations**: createForm, getForm, addQuestion, listResponses - **Docs Operations**: createDocument, insertText, replaceText, applyTextStyle, insertTable - **Gmail Operations**: listMessages, listThreads, getMessage, getThread, searchMessages, createDraft, sendMessage, sendDraft, listLabels, modifyLabels + - **Calendar Operations**: listCalendars, getCalendar, listEvents, getEvent, createEvent, updateEvent, deleteEvent, quickAdd, checkFreeBusy - **Batch Operations**: batchFileOperations (create, update, delete, move multiple files) - **Enhanced Search**: enhancedSearch with natural language parsing - **Transport**: StdioServerTransport for MCP communication diff --git a/Dockerfile b/Dockerfile index 3dc26d1..6e0dd7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN mkdir -p /credentials /app/logs && \ VOLUME ["/credentials"] # Environment variables +# Note: *_PATH vars are file paths, not secrets - Docker warning is a false positive ENV GDRIVE_OAUTH_PATH=/credentials/gcp-oauth.keys.json ENV GDRIVE_TOKEN_STORAGE_PATH=/credentials/.gdrive-mcp-tokens.json ENV GDRIVE_TOKEN_AUDIT_LOG_PATH=/app/logs/gdrive-mcp-audit.log diff --git a/index.ts b/index.ts index 12da8da..1a28606 100644 --- a/index.ts +++ b/index.ts @@ -74,11 +74,24 @@ import type { ModifyLabelsOptions, } from "./src/modules/gmail/index.js"; +import type { + ListCalendarsOptions, + GetCalendarOptions, + ListEventsOptions, + GetEventOptions, + CreateEventOptions, + UpdateEventOptions, + DeleteEventOptions, + QuickAddOptions, + FreeBusyOptions, +} from "./src/modules/calendar/index.js"; + const drive = google.drive("v3"); const sheets = google.sheets("v4"); const forms = google.forms("v1"); const docs = google.docs("v1"); const gmail = google.gmail("v1"); +const calendar = google.calendar("v3"); // Performance monitoring types interface PerformanceStats { @@ -541,6 +554,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, required: ["operation", "params"] } + }, + { + name: "calendar", + description: "Google Calendar operations. Read gdrive://tools resource to see available operations.", + inputSchema: { + type: "object", + properties: { + operation: { + type: "string", + enum: ["listCalendars", "getCalendar", "listEvents", "getEvent", "createEvent", "updateEvent", "deleteEvent", "quickAdd", "checkFreeBusy"], + description: "Operation to perform" + }, + params: { + type: "object", + description: "Operation-specific parameters. See gdrive://tools for details." + } + }, + required: ["operation", "params"] + } } ] }; @@ -568,6 +600,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { forms, docs, gmail, + calendar, cacheManager, performanceMonitor, startTime, @@ -740,6 +773,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } + case "calendar": { + const calendarModule = await import('./src/modules/calendar/index.js'); + + switch (operation) { + case "listCalendars": + result = await calendarModule.listCalendars(params as ListCalendarsOptions, context); + break; + case "getCalendar": + result = await calendarModule.getCalendar(params as GetCalendarOptions, context); + break; + case "listEvents": + result = await calendarModule.listEvents(params as ListEventsOptions, context); + break; + case "getEvent": + result = await calendarModule.getEvent(params as GetEventOptions, context); + break; + case "createEvent": + result = await calendarModule.createEvent(params as CreateEventOptions, context); + break; + case "updateEvent": + result = await calendarModule.updateEvent(params as UpdateEventOptions, context); + break; + case "deleteEvent": + result = await calendarModule.deleteEvent(params as DeleteEventOptions, context); + break; + case "quickAdd": + result = await calendarModule.quickAdd(params as QuickAddOptions, context); + break; + case "checkFreeBusy": + result = await calendarModule.checkFreeBusy(params as FreeBusyOptions, context); + break; + default: + throw new Error(`Unknown calendar operation: ${operation}`); + } + break; + } + default: throw new Error(`Unknown tool: ${name}`); } @@ -791,7 +861,10 @@ async function authenticateAndSaveCredentials() { "https://www.googleapis.com/auth/gmail.readonly", // Read operations: listMessages, getMessage, getThread, searchMessages "https://www.googleapis.com/auth/gmail.send", // messages.send only "https://www.googleapis.com/auth/gmail.compose", // Draft operations: drafts.create, drafts.send - "https://www.googleapis.com/auth/gmail.modify" // Label/message modification: modifyLabels, listLabels + "https://www.googleapis.com/auth/gmail.modify", // Label/message modification: modifyLabels, listLabels + // Calendar scopes (added in v3.3.0) + "https://www.googleapis.com/auth/calendar.readonly", // Read calendars and events + "https://www.googleapis.com/auth/calendar.events" // Full event CRUD (create, update, delete) ], }); diff --git a/package-lock.json b/package-lock.json index 3731a85..dcb247b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/server-gdrive", - "version": "3.1.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/server-gdrive", - "version": "3.1.0", + "version": "3.3.0", "license": "MIT", "dependencies": { "@google-cloud/local-auth": "^3.0.1", @@ -1176,9 +1176,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", - "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.7", @@ -5864,9 +5864,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index 337afbc..ed85b60 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/server-gdrive", - "version": "3.2.0", - "description": "MCP server for Google Workspace with operation-based progressive disclosure - direct API access to Drive, Sheets, Forms, Docs, and Gmail", + "version": "3.3.0", + "description": "MCP server for Google Workspace with operation-based progressive disclosure - direct API access to Drive, Sheets, Forms, Docs, Gmail, and Calendar", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", diff --git a/specs/google-calendar-integration.md b/specs/google-calendar-integration.md new file mode 100644 index 0000000..d68ea36 --- /dev/null +++ b/specs/google-calendar-integration.md @@ -0,0 +1,1502 @@ +# Google Calendar Integration Specification + +**Generated:** Thu Jan 8 12:03:43 CST 2026 +**Status:** Ready for Implementation +**Version:** 1.0.0 + +## Problem Statement + +### Why This Exists +The gdrive MCP server currently provides comprehensive access to Google Workspace (Drive, Sheets, Forms, Docs, Gmail) but lacks calendar functionality. Users need to: +- Schedule meetings with contacts from their PAI contact list +- Manage personal calendar events programmatically +- Coordinate across multiple calendars +- Track events and meetings as part of their AI-assisted workflow + +### Who It's For +- **Primary:** Users of the gdrive MCP server who need calendar operations integrated with their existing workflow +- **Specific:** PAI (Personal AI Infrastructure) users who want to schedule meetings with their contacts (Mary, Kelvin, Giauna, Elihu, Ayaba, etc.) by name rather than manually typing emails + +### Cost of Not Doing It +Without calendar integration: +- Users must manually switch between MCP tools and Google Calendar UI +- Cannot programmatically schedule meetings as part of automated workflows +- Missing calendar context in PAI Memory System (can't track meeting history) +- No integration between emails (Gmail module) and calendar events +- Cannot leverage contact list for quick meeting invites + +## Technical Requirements + +### Architecture Pattern +**Operation-based with progressive disclosure** (matching Gmail/Drive/Sheets modules) + +```typescript +// MCP Tool Definition +{ + name: "calendar", + description: "Google Calendar operations. Read gdrive://tools resource to see available operations.", + inputSchema: { + type: "object", + properties: { + operation: { + type: "string", + enum: [ + // Calendar Management + "listCalendars", + "getCalendar", + + // Event CRUD + "listEvents", + "getEvent", + "createEvent", + "updateEvent", + "deleteEvent", + + // Advanced Operations + "quickAdd", + "checkFreeBusy" + ], + description: "Operation to perform" + }, + params: { + type: "object", + description: "Operation-specific parameters. See gdrive://tools for details." + } + }, + required: ["operation", "params"] + } +} +``` + +### Module Structure +Following established patterns from Gmail module (v3.2.0): + +``` +src/modules/calendar/ +โ”œโ”€โ”€ index.ts # Public API exports (types + operations) +โ”œโ”€โ”€ types.ts # TypeScript interfaces and types +โ”œโ”€โ”€ list.ts # listCalendars, listEvents +โ”œโ”€โ”€ read.ts # getCalendar, getEvent +โ”œโ”€โ”€ create.ts # createEvent, quickAdd +โ”œโ”€โ”€ update.ts # updateEvent +โ”œโ”€โ”€ delete.ts # deleteEvent +โ”œโ”€โ”€ freebusy.ts # checkFreeBusy +โ””โ”€โ”€ contacts.ts # PAI contact name resolution +``` + +### Data Models + +#### CalendarContext +```typescript +export interface CalendarContext extends BaseContext { + calendar: calendar_v3.Calendar; // Google Calendar API v3 +} +``` + +#### Event Options +```typescript +export interface CreateEventOptions { + calendarId?: string; // Default: 'primary' + summary: string; // Event title + description?: string; // Event details + location?: string; // Physical or virtual location + start: EventDateTime; // Start time with timezone + end: EventDateTime; // End time with timezone + attendees?: string[]; // Email addresses OR contact names + recurrence?: string[]; // RRULE strings (RFC 5545) + conferenceData?: ConferenceData; // Google Meet or other video links + attachments?: EventAttachment[]; // Drive file links + reminders?: ReminderSettings; // Notification settings +} + +export interface EventDateTime { + dateTime?: string; // ISO 8601 format: '2026-01-08T14:00:00-06:00' + date?: string; // All-day events: '2026-01-08' + timeZone?: string; // IANA timezone: 'America/Chicago' +} + +export interface ConferenceData { + createRequest?: { + requestId: string; // UUID for idempotency + conferenceSolutionKey: { + type: 'hangoutsMeet' | 'eventHangout' | 'eventNamedHangout'; + }; + }; +} +``` + +#### List/Search Options +```typescript +export interface ListEventsOptions { + calendarId?: string; // Default: 'primary' + timeMin?: string; // ISO 8601: filter events after this time + timeMax?: string; // ISO 8601: filter events before this time + maxResults?: number; // Default: 10, max: 2500 + pageToken?: string; // For pagination + singleEvents?: boolean; // Expand recurring events (default: true) + orderBy?: 'startTime' | 'updated'; + showDeleted?: boolean; // Include deleted events + timeZone?: string; // Response timezone +} +``` + +### PAI Contact Integration + +#### Contact Resolution Strategy +**Environment Variable:** `PAI_CONTACTS_PATH` +**Default:** `/Users/ossieirondi/PAI/.claude/skills/CORE/USER/CONTACTS.md` + +#### Contact Name Resolution +```typescript +// src/modules/calendar/contacts.ts + +/** + * Resolve contact names to email addresses from PAI contact list + * + * Supports: + * - First names: "Mary" -> "findsbymary@gmail.com" + * - Raw emails: "user@example.com" -> "user@example.com" + * - Mixed: ["Mary", "user@example.com"] -> ["findsbymary@gmail.com", "user@example.com"] + */ +export async function resolveContacts( + names: string[], + logger: Logger +): Promise<{ email: string; displayName?: string }[]> +``` + +#### Contact List Format Parsing +Parse PAI CONTACTS.md format: +```markdown +- **Mary** [Wife/Life Partner] - findsbymary@gmail.com +- **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com +``` + +Extract mapping: +```typescript +{ + "mary": { email: "findsbymary@gmail.com", displayName: "Mary", role: "Wife/Life Partner" }, + "kelvin": { email: "mmesomakelvin@gmail.com", displayName: "Kelvin", role: "Junior Developer" } +} +``` + +#### Error Handling for Contact Resolution +- **Unknown contact name:** Return clear error: `"Contact 'Bob' not found in PAI contact list. Available contacts: Mary, Kelvin, Giauna, Elihu, Ayaba, Veronica, Uzoamaka"` +- **Missing contacts file:** Log warning, treat all inputs as raw email addresses +- **Invalid email format:** Validate with regex, return error for invalid emails + +### Performance Constraints +- **Cached reads:** < 500ms (listEvents, getEvent, getCalendar) +- **Write operations:** < 1s (createEvent, updateEvent, deleteEvent) +- **Batch operations:** < 3s for up to 10 events + +### Caching Strategy (Redis) +Following Gmail module pattern: + +```typescript +// Cache keys +const cacheKey = `calendar:${operation}:${JSON.stringify(params)}`; + +// Cache invalidation +await cacheManager.invalidate('calendar:listEvents:*'); // After create/update/delete +await cacheManager.invalidate('calendar:getEvent:*'); // After specific event update + +// TTL: 300 seconds (5 minutes) - matches Gmail/Drive +``` + +**Operations to cache:** +- โœ… `listCalendars` - calendars change infrequently +- โœ… `getCalendar` - metadata rarely changes +- โœ… `listEvents` - cache with time range in key +- โœ… `getEvent` - cache individual event details +- โŒ `createEvent` - write operation, invalidates caches +- โŒ `updateEvent` - write operation, invalidates caches +- โŒ `deleteEvent` - write operation, invalidates caches +- โš ๏ธ `checkFreeBusy` - cache with short TTL (60s), highly time-sensitive + +### Integration Points + +#### 1. Google Calendar API v3 +```typescript +import { google } from 'googleapis'; +const calendar = google.calendar('v3'); + +// Initialize in index.ts +const calendar = google.calendar('v3'); + +// Build context for operations +const context = { + logger, + calendar, + cacheManager, + performanceMonitor, + startTime, +}; +``` + +#### 2. OAuth Scopes (Authentication) +Add to index.ts authentication flow: + +```typescript +const auth = await authenticate({ + keyfilePath: oauthPath, + scopes: [ + // ... existing scopes ... + "https://www.googleapis.com/auth/calendar.readonly", // Read calendars + "https://www.googleapis.com/auth/calendar.events", // Manage events + ], +}); +``` + +**Scope rationale:** +- `calendar.readonly` - List calendars, view event details +- `calendar.events` - Full event CRUD (create, update, delete) +- **NOT** `calendar` (full access) - Excludes calendar ACL management per scope decisions + +#### 3. Main Server (index.ts) +Add calendar tool to ListToolsRequestSchema handler: + +```typescript +{ + name: "calendar", + description: "Google Calendar operations. Read gdrive://tools resource to see available operations.", + inputSchema: { + type: "object", + properties: { + operation: { + type: "string", + enum: ["listCalendars", "getCalendar", "listEvents", "getEvent", "createEvent", "updateEvent", "deleteEvent", "quickAdd", "checkFreeBusy"], + description: "Operation to perform" + }, + params: { + type: "object", + description: "Operation-specific parameters. See gdrive://tools for details." + } + }, + required: ["operation", "params"] + } +} +``` + +Add calendar case to CallToolRequestSchema handler: + +```typescript +case "calendar": { + const calendarModule = await import('./src/modules/calendar/index.js'); + + switch (operation) { + case "listCalendars": + result = await calendarModule.listCalendars(params, context); + break; + case "getCalendar": + result = await calendarModule.getCalendar(params, context); + break; + case "listEvents": + result = await calendarModule.listEvents(params, context); + break; + case "getEvent": + result = await calendarModule.getEvent(params, context); + break; + case "createEvent": + result = await calendarModule.createEvent(params, context); + break; + case "updateEvent": + result = await calendarModule.updateEvent(params, context); + break; + case "deleteEvent": + result = await calendarModule.deleteEvent(params, context); + break; + case "quickAdd": + result = await calendarModule.quickAdd(params, context); + break; + case "checkFreeBusy": + result = await calendarModule.checkFreeBusy(params, context); + break; + default: + throw new Error(`Unknown calendar operation: ${operation}`); + } + break; +} +``` + +#### 4. Tool Discovery (src/tools/listTools.ts) +Add calendar operations to generateToolStructure(): + +```typescript +calendar: [ + { + name: 'listCalendars', + signature: 'listCalendars({ maxResults?: number, pageToken?: string })', + description: 'List all calendars accessible by the user', + example: 'calendar.listCalendars({ maxResults: 10 })', + }, + { + name: 'getCalendar', + signature: 'getCalendar({ calendarId: string })', + description: 'Get details of a specific calendar', + example: 'calendar.getCalendar({ calendarId: "primary" })', + }, + { + name: 'listEvents', + signature: 'listEvents({ calendarId?: string, timeMin?: string, timeMax?: string, maxResults?: number })', + description: 'List events in a calendar within a time range', + example: 'calendar.listEvents({ timeMin: "2026-01-08T00:00:00Z", maxResults: 20 })', + }, + { + name: 'getEvent', + signature: 'getEvent({ calendarId?: string, eventId: string })', + description: 'Get details of a specific event', + example: 'calendar.getEvent({ eventId: "abc123" })', + }, + { + name: 'createEvent', + signature: 'createEvent({ summary: string, start: EventDateTime, end: EventDateTime, attendees?: string[], ... })', + description: 'Create a new calendar event with optional attendees and recurrence', + example: 'calendar.createEvent({ summary: "Team Standup", start: { dateTime: "2026-01-09T09:00:00-06:00" }, end: { dateTime: "2026-01-09T09:30:00-06:00" }, attendees: ["Mary", "Kelvin"] })', + }, + { + name: 'updateEvent', + signature: 'updateEvent({ eventId: string, updates: Partial })', + description: 'Update an existing event', + example: 'calendar.updateEvent({ eventId: "abc123", updates: { summary: "Updated Meeting Title" } })', + }, + { + name: 'deleteEvent', + signature: 'deleteEvent({ eventId: string, sendUpdates?: "all" | "externalOnly" | "none" })', + description: 'Delete an event and optionally notify attendees', + example: 'calendar.deleteEvent({ eventId: "abc123", sendUpdates: "all" })', + }, + { + name: 'quickAdd', + signature: 'quickAdd({ text: string, calendarId?: string })', + description: 'Create an event from natural language text', + example: 'calendar.quickAdd({ text: "Lunch with Mary tomorrow at noon" })', + }, + { + name: 'checkFreeBusy', + signature: 'checkFreeBusy({ timeMin: string, timeMax: string, items: { id: string }[] })', + description: 'Check availability for calendars or attendees in a time range', + example: 'calendar.checkFreeBusy({ timeMin: "2026-01-09T00:00:00Z", timeMax: "2026-01-09T23:59:59Z", items: [{ id: "primary" }] })', + }, +], +``` + +## Edge Cases & Error Handling + +### Input Validation + +#### Event Times +- **Past events:** โœ… ALLOWED - Users may want to log past meetings +- **Invalid date format:** โŒ ERROR - Return clear message: `"Invalid date format. Use ISO 8601: '2026-01-08T14:00:00-06:00'"` +- **End before start:** โŒ ERROR - Return: `"Event end time must be after start time"` +- **All-day event with time:** โŒ ERROR - Return: `"All-day events should use 'date' field, not 'dateTime'"` + +#### Attendees +- **Email validation:** Basic regex check (`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`) +- **Unknown contact name:** โŒ ERROR - Return list of available contacts +- **Mixed valid/invalid:** Process valid emails, return error for invalid ones +- **Empty attendee list:** โœ… ALLOWED - Create event without attendees + +#### Recurring Events (RRULE) +- **Valid RRULE:** `["RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10"]` +- **Invalid RRULE syntax:** โŒ ERROR - Return: `"Invalid RRULE format. See RFC 5545 specification"` +- **Conflicting rules:** Let Google Calendar API validate, return API error + +#### Conference Data +- **Google Meet auto-creation:** Set `conferenceSolutionKey.type = 'hangoutsMeet'` + `requestId` +- **External links (Zoom, Teams):** Add as string in `description` or `location` field +- **Missing requestId:** Generate UUID automatically for idempotency + +### Operation Failures + +#### Network/API Errors +```typescript +try { + const response = await context.calendar.events.insert({ ... }); +} catch (error) { + if (error.code === 403) { + // Permission denied + return { + success: false, + error: { + message: "Permission denied. Check OAuth scopes and calendar sharing settings.", + stack: error.stack, + } + }; + } + if (error.code === 404) { + // Calendar or event not found + return { + success: false, + error: { + message: `Calendar or event not found: ${error.message}`, + stack: error.stack, + } + }; + } + // Generic error + logger.error('Calendar operation failed', { error, operation: 'createEvent' }); + return { + success: false, + error: { + message: error.message || 'Unknown error occurred', + stack: error.stack, + } + }; +} +``` + +#### Quota Limits +Google Calendar API quotas: +- **Queries per day:** 1,000,000 (unlikely to hit) +- **Queries per 100 seconds:** 10,000 +- **Create events per day:** 100,000 + +**Handling:** +- Return quota error from API: `"Rate limit exceeded. Try again in a few minutes."` +- Log quota errors for monitoring +- โŒ NO automatic retry with exponential backoff (per scope decision - keep it simple) + +#### Conflict Detection +```typescript +// After creating/updating an event, check for overlaps +const conflicts = await detectConflicts(newEvent, context); + +if (conflicts.length > 0) { + logger.warn('Event conflicts detected', { + eventId: newEvent.id, + conflicts: conflicts.map(c => ({ + id: c.id, + summary: c.summary, + start: c.start, + end: c.end, + })), + }); + + // Return warning in response + return { + success: true, + data: { + event: newEvent, + warnings: [{ + type: 'conflict', + message: `This event overlaps with ${conflicts.length} existing event(s)`, + conflicts: conflicts.map(c => c.summary), + }], + }, + }; +} +``` + +**Conflict logic:** +- Check for time overlap: `newStart < existingEnd && newEnd > existingStart` +- Only check against accepted events (not declined/tentative) +- โš ๏ธ WARN but ALLOW - User manages their schedule + +### Time Zone Handling + +#### Default Behavior +- Use user's primary calendar timezone if not specified +- Always include timezone in API responses +- Support IANA timezone names: `America/Chicago`, `Europe/London`, `Asia/Tokyo` + +#### Edge Cases +- **Missing timezone:** Use calendar default or UTC +- **Invalid timezone:** โŒ ERROR - Return list of valid IANA timezones +- **Daylight Saving Time:** Let Google Calendar API handle DST transitions +- **Cross-timezone events:** Store in user's specified timezone, display logic handled by Google Calendar + +### Contact Resolution Edge Cases + +#### PAI Contacts File +- **File not found:** Log warning, treat all attendees as raw emails +- **Parse error:** Log error, fallback to raw emails +- **Duplicate names:** Use first match, log warning +- **Case sensitivity:** Match case-insensitive: "mary" === "Mary" + +#### Environment Variable +```typescript +const contactsPath = process.env.PAI_CONTACTS_PATH + || '/Users/ossieirondi/PAI/.claude/skills/CORE/USER/CONTACTS.md'; +``` + +## User Experience + +### Mental Model +Users should think: "I'm talking to my calendar directly through the MCP" + +**Simple use case:** +```typescript +// Natural thought: "Schedule a meeting with Mary tomorrow at 2pm" +calendar.createEvent({ + summary: "Catch up with Mary", + start: { dateTime: "2026-01-09T14:00:00-06:00", timeZone: "America/Chicago" }, + end: { dateTime: "2026-01-09T15:00:00-06:00", timeZone: "America/Chicago" }, + attendees: ["Mary"] // Resolves to findsbymary@gmail.com +}) +``` + +**Alternative: Quick Add** +```typescript +calendar.quickAdd({ + text: "Lunch with Mary tomorrow at noon" +}) +// Google parses natural language, creates event +``` + +### Confusion Points & Solutions + +#### 1. "Do I use contact names or emails?" +**Solution:** Support BOTH transparently +```typescript +attendees: ["Mary", "external@company.com", "Kelvin"] +// Resolves to: ["findsbymary@gmail.com", "external@company.com", "mmesomakelvin@gmail.com"] +``` + +#### 2. "How do I create recurring events?" +**Solution:** Provide clear RRULE examples in docs +```typescript +// Weekly standup every Monday at 9am +{ + summary: "Team Standup", + start: { dateTime: "2026-01-13T09:00:00-06:00" }, // First Monday + end: { dateTime: "2026-01-13T09:30:00-06:00" }, + recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"] +} +``` + +#### 3. "What timezone should I use?" +**Solution:** Document timezone behavior clearly +- Specify `timeZone` in `start`/`end` for explicit control +- Omit for user's default calendar timezone +- Use ISO 8601 with offset: `2026-01-09T14:00:00-06:00` + +#### 4. "How do I add a Google Meet link?" +**Solution:** Simple boolean flag +```typescript +{ + summary: "Video call", + start: { ... }, + end: { ... }, + conferenceData: { + createRequest: { + requestId: crypto.randomUUID(), + conferenceSolutionKey: { type: 'hangoutsMeet' } + } + } +} +``` + +### Feedback at Each Step + +#### Event Creation +```typescript +{ + success: true, + data: { + eventId: "abc123xyz", + htmlLink: "https://calendar.google.com/event?eid=abc123xyz", + summary: "Team Standup", + start: "2026-01-09T09:00:00-06:00", + end: "2026-01-09T09:30:00-06:00", + attendees: [ + { email: "findsbymary@gmail.com", displayName: "Mary", responseStatus: "needsAction" }, + { email: "mmesomakelvin@gmail.com", displayName: "Kelvin", responseStatus: "needsAction" } + ], + created: "2026-01-08T18:03:43Z", + warnings: [] // Includes conflict warnings if any + } +} +``` + +#### List Events +```typescript +{ + success: true, + data: { + events: [ + { + id: "abc123", + summary: "Team Standup", + start: "2026-01-09T09:00:00-06:00", + end: "2026-01-09T09:30:00-06:00", + attendees: 2, + status: "confirmed" + }, + // ... more events + ], + nextPageToken: "token123", + timeZone: "America/Chicago" + } +} +``` + +#### Error Feedback +```typescript +{ + success: false, + error: { + message: "Contact 'Bob' not found in PAI contact list. Available contacts: Mary, Kelvin, Giauna, Elihu, Ayaba, Veronica, Uzoamaka", + stack: "..." + } +} +``` + +## Scope & Tradeoffs + +### In Scope (MVP - Full Scheduling) + +#### Essential Operations +- โœ… **Calendar Management** + - List accessible calendars + - Get calendar metadata + +- โœ… **Event CRUD** + - Create events with attendees + - List events (with time range filtering) + - Get event details + - Update existing events + - Delete events with notification options + +- โœ… **Advanced Features** + - Recurring events (full RFC 5545 RRULE support) + - Time zone support (IANA timezones) + - Google Meet conference links (auto-generation) + - Event attachments (Drive file links) + - Reminder settings + +- โœ… **Nice-to-Have Operations** + - Quick Add (natural language event creation) + - FreeBusy queries (check availability) + +- โœ… **PAI Integration** + - Contact name resolution (Mary โ†’ findsbymary@gmail.com) + - Environment variable for contacts path + - Integration with PAI Memory System logging + +- โœ… **Cross-Module Integration** + - Gmail thread linking (reference emails in events) + - Drive file attachments + - Linear task/issue linking + +### Explicitly Out of Scope + +#### Calendar Permissions/ACLs +- โŒ Managing calendar sharing settings +- โŒ Modifying calendar access control lists +- โŒ Creating/deleting calendars themselves +- **Rationale:** Complexity vs. value - users rarely need programmatic ACL management + +#### Advanced Scheduling Intelligence +- โŒ AI-powered "find time" suggestions +- โŒ Automatic optimal slot detection +- โŒ Smart rescheduling algorithms +- **Rationale:** Complex feature requiring significant development, can use manual scheduling or external tools + +#### External Meeting Platform Integration +- โŒ Creating Zoom/Teams/Slack meeting links programmatically +- โŒ Managing external platform authentication +- **Rationale:** Each platform requires separate OAuth/API integration, out of scope for calendar-focused feature + +### Technical Debt Accepted + +#### 1. Hard-coded PAI Contact List Path +```typescript +const contactsPath = process.env.PAI_CONTACTS_PATH + || '/Users/ossieirondi/PAI/.claude/skills/CORE/USER/CONTACTS.md'; +``` +- **Debt:** Tightly coupled to PAI environment structure +- **Future fix:** Could abstract to general contact provider interface +- **Acceptable because:** 90% of users will use PAI, custom path via env var for others + +#### 2. No Offline Calendar Support +- **Debt:** Requires active internet and Google API access +- **Future fix:** Could add local caching of calendar data for offline viewing +- **Acceptable because:** Calendar operations inherently require sync with Google servers + +#### 3. Simple Conflict Detection +- **Debt:** Basic time overlap checking, no sophisticated scheduling logic +- **Future fix:** Could add multi-calendar conflict detection, tentative event handling +- **Acceptable because:** Provides warnings without blocking, users manage their schedules + +#### 4. Limited Batch Operations Initially +- **Debt:** No batch event creation/update in MVP +- **Future fix:** Add `batchEvents` operation similar to Drive's `batchOperations` +- **Acceptable because:** Single event operations work fine, batch can be added later + +#### 5. Redis Cache Encryption +- **Debt:** Calendar data cached in Redis without encryption +- **Future fix:** Implement encryption layer for sensitive calendar data in cache +- **Mitigation:** Short TTL (5 min), Redis should be on private network +- **Acceptable because:** Performance priority, sensitive data exposure window is small + +## Integration Requirements + +### Systems Affected + +#### 1. Authentication System (index.ts) +**Change:** Add Google Calendar API scopes + +```typescript +scopes: [ + // ... existing scopes ... + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +] +``` + +**Migration:** Users must re-authenticate to grant calendar permissions +- Run: `node ./dist/index.js auth` +- Opens browser for OAuth consent +- New scopes added to existing token + +#### 2. MCP Server (index.ts) +**Changes:** +- Add `calendar` tool to ListToolsRequestSchema handler +- Add `calendar` case to CallToolRequestSchema handler +- Import calendar module operations +- Initialize Google Calendar API v3 client + +```typescript +const calendar = google.calendar('v3'); + +const context = { + logger, + drive, + sheets, + forms, + docs, + gmail, + calendar, // NEW + cacheManager, + performanceMonitor, + startTime, +}; +``` + +#### 3. Tool Discovery (src/tools/listTools.ts) +**Change:** Add calendar operations to `generateToolStructure()` + +See "Integration Points" section for complete calendar operation list. + +#### 4. Type Definitions (src/modules/types.ts) +**Change:** Add CalendarContext interface + +```typescript +export interface CalendarContext extends BaseContext { + calendar: calendar_v3.Calendar; +} +``` + +#### 5. Gmail Module (Optional Enhancement) +**Future integration:** Link Gmail threads to calendar events + +```typescript +// In Gmail read.ts +if (message.payload.headers.find(h => h.name === 'X-Google-Calendar-Event-Id')) { + // Extract calendar event ID, provide link +} +``` + +#### 6. Linear Module (Optional Enhancement) +**Future integration:** Sync calendar events with Linear issues + +```typescript +// Create Linear issue when event created with specific label +if (event.summary.includes('[LINEAR]')) { + await linear.issues.create({ ... }); +} +``` + +#### 7. PAI Memory System +**Integration:** Log calendar operations to PAI MEMORY + +```typescript +// In create.ts, update.ts, delete.ts +logger.info('Calendar event created', { + eventId: result.id, + summary: result.summary, + attendees: result.attendees?.map(a => a.email), +}); + +// Future: Write to ${PAI_DIR}/MEMORY/execution/calendar-operations.log +``` + +### Migration Path + +#### Existing User Data +**Scenario:** Users already have Google Calendars with existing events + +**Approach:** +1. No data migration needed - MCP reads existing calendars via API +2. First run requires OAuth re-authentication to grant calendar scopes +3. Existing events immediately accessible via `listEvents`, `getEvent` + +#### Authentication Migration +```bash +# Users must run: +node ./dist/index.js auth + +# This will: +# 1. Open browser for OAuth consent +# 2. Show new calendar scopes being requested +# 3. Save updated tokens with calendar permissions +# 4. Server ready to access calendar API +``` + +#### Backward Compatibility +- โœ… No breaking changes to existing modules (Drive, Sheets, Forms, Docs, Gmail) +- โœ… Calendar is additive feature +- โœ… Users without re-auth simply won't have calendar access (graceful degradation) + +## Security & Compliance + +### Sensitive Data Handling + +#### Event Details +**Sensitive:** Titles, descriptions, locations may contain confidential information +- **In transit:** HTTPS to Google Calendar API (Google handles TLS) +- **At rest (Redis cache):** + - โš ๏ธ **Not encrypted in MVP** (accepted technical debt) + - Mitigation: 5-minute TTL, private Redis instance + - Future: Implement cache encryption layer + +#### Attendee Information +**Sensitive:** Email addresses, names from PAI contact list +- **Contact list file:** Read-only access to PAI CONTACTS.md +- **In memory:** Contact mapping held briefly during operation +- **Logging:** Email addresses logged (Winston file logs should be secured) + +#### Video Conference Links +**Sensitive:** Google Meet links may contain join codes +- **Cached:** Yes (part of event data) +- **TTL:** 5 minutes +- **Exposure risk:** Low (links are shareable by design, but cached briefly) + +#### Attachments +**Sensitive:** Drive file IDs in event attachments +- **Access control:** Follows Google Drive sharing settings +- **MCP responsibility:** Pass through file IDs, don't validate permissions +- **User responsibility:** Ensure Drive files are shared with attendees + +### Authentication & Authorization + +#### OAuth Scopes Required +```typescript +"https://www.googleapis.com/auth/calendar.readonly" // Read calendars and events +"https://www.googleapis.com/auth/calendar.events" // Create, update, delete events +``` + +**NOT included:** +- `calendar` (full access) - Would allow calendar deletion, ACL management + +#### Token Management +**Pattern:** Follow existing Gmail/Drive token management (AuthManager, TokenManager) + +```typescript +// In index.ts +authManager = AuthManager.getInstance(oauthKeys, logger); +await authManager.initialize(); + +const oauth2Client = authManager.getOAuth2Client(); +google.options({ auth: oauth2Client }); +``` + +**Token refresh:** Automatic via AuthManager +**Token storage:** Encrypted via TokenManager with `GDRIVE_TOKEN_ENCRYPTION_KEY` + +#### Permission Verification +**Strategy:** Trust Google Calendar API for authorization checks + +```typescript +// No pre-flight permission checks +// Let API return 403 if user lacks access to calendar +// Return clear error message to user +``` + +**Rationale:** Google Calendar API handles complex ACL logic, duplicating checks adds complexity without value + +### Data Privacy + +#### User Consent +- Users explicitly grant calendar access during OAuth flow +- Scopes clearly labeled in Google consent screen +- User can revoke access at any time via Google Account settings + +#### Data Retention +- **Redis cache:** 5-minute TTL, auto-expires +- **Winston logs:** Rotated after 5 files ร— 5MB (per existing config) +- **No persistent storage:** Calendar data only in Google's servers + +#### Cross-Module Data Sharing +- Calendar module can access PAI contacts (read-only) +- Gmail module could link to calendar events (future enhancement) +- Linear module could reference calendar events (future enhancement) +- **All cross-module data access logged for audit** + +## Success Criteria & Testing + +### Acceptance Criteria + +#### 1. Can Create Event and Invite PAI Contacts by Name โœ… +```typescript +// Test case +const result = await calendar.createEvent({ + summary: "Meeting with Mary and Kelvin", + start: { dateTime: "2026-01-09T14:00:00-06:00" }, + end: { dateTime: "2026-01-09T15:00:00-06:00" }, + attendees: ["Mary", "Kelvin"] // Contact names resolved +}); + +// Expected +assert(result.success === true); +assert(result.data.attendees.find(a => a.email === 'findsbymary@gmail.com')); +assert(result.data.attendees.find(a => a.email === 'mmesomakelvin@gmail.com')); +``` + +#### 2. Can View Upcoming Events for the Week โœ… +```typescript +// Test case +const today = new Date(); +const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); + +const result = await calendar.listEvents({ + timeMin: today.toISOString(), + timeMax: nextWeek.toISOString(), + singleEvents: true, + orderBy: 'startTime' +}); + +// Expected +assert(result.success === true); +assert(Array.isArray(result.data.events)); +assert(result.data.events.every(e => e.start && e.end)); +``` + +#### 3. Can Update/Delete Events Without Errors โœ… +```typescript +// Update test +const updateResult = await calendar.updateEvent({ + eventId: testEventId, + updates: { + summary: "Updated Meeting Title", + location: "Conference Room B" + } +}); + +assert(updateResult.success === true); +assert(updateResult.data.summary === "Updated Meeting Title"); + +// Delete test +const deleteResult = await calendar.deleteEvent({ + eventId: testEventId, + sendUpdates: "all" +}); + +assert(deleteResult.success === true); +``` + +#### 4. All Tests Pass with >80% Coverage โœ… +```bash +npm run test:coverage + +# Expected output: +# ------------------------|---------|----------|---------|---------| +# File | % Stmts | % Branch | % Funcs | % Lines | +# ------------------------|---------|----------|---------|---------| +# All files | 85.23 | 78.45 | 89.12 | 86.34 | +# modules/calendar | 87.50 | 82.00 | 92.00 | 88.75 | +# index.ts | 100 | 100 | 100 | 100 | +# create.ts | 85.00 | 80.00 | 90.00 | 86.00 | +# list.ts | 90.00 | 85.00 | 95.00 | 91.00 | +# read.ts | 88.00 | 83.00 | 91.00 | 89.00 | +# update.ts | 85.00 | 78.00 | 88.00 | 86.00 | +# delete.ts | 87.00 | 82.00 | 90.00 | 88.00 | +# contacts.ts | 82.00 | 75.00 | 85.00 | 83.00 | +# freebusy.ts | 80.00 | 72.00 | 82.00 | 81.00 | +# ------------------------|---------|----------|---------|---------| +``` + +### Test Approach + +#### 1. Unit Tests (per operation) +**Pattern:** Match Gmail module test structure + +```typescript +// src/modules/calendar/__tests__/create.test.ts +describe('createEvent', () => { + let mockContext: CalendarContext; + + beforeEach(() => { + mockContext = { + logger: mockLogger, + calendar: mockCalendarApi, + cacheManager: mockCache, + performanceMonitor: mockMonitor, + startTime: Date.now(), + }; + }); + + test('creates basic event', async () => { + const result = await createEvent({ + summary: 'Test Event', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }, mockContext); + + expect(result.success).toBe(true); + expect(result.data.summary).toBe('Test Event'); + }); + + test('resolves PAI contact names', async () => { + const result = await createEvent({ + summary: 'Meeting', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + attendees: ['Mary'], + }, mockContext); + + expect(result.data.attendees[0].email).toBe('findsbymary@gmail.com'); + }); + + test('handles unknown contact name error', async () => { + const result = await createEvent({ + summary: 'Meeting', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + attendees: ['UnknownPerson'], + }, mockContext); + + expect(result.success).toBe(false); + expect(result.error.message).toContain('not found in PAI contact list'); + }); +}); +``` + +**Files to create:** +- `src/modules/calendar/__tests__/create.test.ts` (~15 tests) +- `src/modules/calendar/__tests__/list.test.ts` (~12 tests) +- `src/modules/calendar/__tests__/read.test.ts` (~8 tests) +- `src/modules/calendar/__tests__/update.test.ts` (~10 tests) +- `src/modules/calendar/__tests__/delete.test.ts` (~6 tests) +- `src/modules/calendar/__tests__/contacts.test.ts` (~10 tests) +- `src/modules/calendar/__tests__/freebusy.test.ts` (~5 tests) + +**Total:** ~66 unit tests + +#### 2. Integration Tests +**Pattern:** Mock Google Calendar API responses + +```typescript +// src/modules/calendar/__tests__/integration/calendar-workflow.test.ts +describe('Calendar Integration Tests', () => { + test('full event lifecycle', async () => { + // Create event + const created = await createEvent({ ... }, context); + expect(created.success).toBe(true); + const eventId = created.data.eventId; + + // Read event + const read = await getEvent({ eventId }, context); + expect(read.success).toBe(true); + expect(read.data.summary).toBe(created.data.summary); + + // Update event + const updated = await updateEvent({ + eventId, + updates: { location: 'New Location' } + }, context); + expect(updated.data.location).toBe('New Location'); + + // Delete event + const deleted = await deleteEvent({ eventId }, context); + expect(deleted.success).toBe(true); + }); +}); +``` + +**Files to create:** +- `src/modules/calendar/__tests__/integration/calendar-workflow.test.ts` +- `src/modules/calendar/__tests__/integration/recurring-events.test.ts` +- `src/modules/calendar/__tests__/integration/contact-resolution.test.ts` + +#### 3. Edge Case Tests + +```typescript +// src/modules/calendar/__tests__/edge-cases.test.ts +describe('Edge Cases', () => { + test('recurring weekly event with RRULE', async () => { + const result = await createEvent({ + summary: 'Weekly Standup', + start: { dateTime: '2026-01-13T09:00:00-06:00' }, + end: { dateTime: '2026-01-13T09:30:00-06:00' }, + recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10'], + }, context); + + expect(result.success).toBe(true); + expect(result.data.recurrence).toContain('FREQ=WEEKLY'); + }); + + test('cross-timezone event', async () => { + const result = await createEvent({ + summary: 'International Call', + start: { + dateTime: '2026-01-09T14:00:00', + timeZone: 'America/New_York' + }, + end: { + dateTime: '2026-01-09T15:00:00', + timeZone: 'America/New_York' + }, + }, context); + + expect(result.success).toBe(true); + }); + + test('event conflict warning', async () => { + // Create first event + await createEvent({ + summary: 'Event 1', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }, context); + + // Create overlapping event + const result = await createEvent({ + summary: 'Event 2', + start: { dateTime: '2026-01-09T14:30:00Z' }, + end: { dateTime: '2026-01-09T15:30:00Z' }, + }, context); + + expect(result.success).toBe(true); + expect(result.data.warnings).toBeDefined(); + expect(result.data.warnings[0].type).toBe('conflict'); + }); +}); +``` + +#### 4. Testing Strategy Summary +- โœ… **Unit tests:** Each operation function tested in isolation +- โœ… **Integration tests:** Full workflows with mocked Google API +- โœ… **Edge cases:** Recurring events, timezones, conflicts, contact resolution +- โœ… **Pattern matching:** Follow Gmail/Sheets test structure +- โœ… **Coverage target:** >80% overall, >85% for core operations + +## Implementation Notes + +### Files to Modify + +#### 1. index.ts (Main Server) +**Lines to change:** +- Line 81: Add `const calendar = google.calendar('v3');` +- Line 564-574: Add calendar to context object +- Line 447-546: Add calendar tool to ListToolsRequestSchema handler +- Line 741: Add calendar case to CallToolRequestSchema handler +- Line 789: Add calendar scopes to authenticate() call + +**Estimated changes:** ~100 lines added + +#### 2. src/tools/listTools.ts +**Function:** `generateToolStructure()` +**Changes:** Add calendar operations to returned structure (see Integration Points section) + +**Estimated changes:** ~50 lines added + +#### 3. src/modules/types.ts +**Changes:** Add CalendarContext interface + +**Estimated changes:** ~5 lines added + +### Files to Create + +#### Core Module Files +``` +src/modules/calendar/ +โ”œโ”€โ”€ index.ts # Public API exports (~40 lines) +โ”œโ”€โ”€ types.ts # TypeScript interfaces (~200 lines) +โ”œโ”€โ”€ list.ts # listCalendars, listEvents (~180 lines) +โ”œโ”€โ”€ read.ts # getCalendar, getEvent (~120 lines) +โ”œโ”€โ”€ create.ts # createEvent, quickAdd (~250 lines) +โ”œโ”€โ”€ update.ts # updateEvent (~150 lines) +โ”œโ”€โ”€ delete.ts # deleteEvent (~80 lines) +โ”œโ”€โ”€ freebusy.ts # checkFreeBusy (~100 lines) +โ””โ”€โ”€ contacts.ts # PAI contact resolution (~150 lines) +``` + +**Total new code:** ~1,270 lines + +#### Test Files +``` +src/modules/calendar/__tests__/ +โ”œโ”€โ”€ create.test.ts # ~400 lines +โ”œโ”€โ”€ list.test.ts # ~350 lines +โ”œโ”€โ”€ read.test.ts # ~250 lines +โ”œโ”€โ”€ update.test.ts # ~300 lines +โ”œโ”€โ”€ delete.test.ts # ~200 lines +โ”œโ”€โ”€ contacts.test.ts # ~350 lines +โ”œโ”€โ”€ freebusy.test.ts # ~180 lines +โ”œโ”€โ”€ edge-cases.test.ts # ~400 lines +โ””โ”€โ”€ integration/ + โ”œโ”€โ”€ calendar-workflow.test.ts # ~350 lines + โ”œโ”€โ”€ recurring-events.test.ts # ~300 lines + โ””โ”€โ”€ contact-resolution.test.ts # ~250 lines +``` + +**Total test code:** ~3,330 lines + +### Patterns to Follow + +#### 1. Operation Structure (from Gmail module) +```typescript +export async function operationName( + options: OperationOptions, + context: CalendarContext +): Promise { + // 1. Destructure options with defaults + const { param1 = default1, param2 } = options; + + // 2. Check cache + const cacheKey = `calendar:operation:${JSON.stringify(options)}`; + const cached = await context.cacheManager.get(cacheKey); + if (cached) { + context.performanceMonitor.track('calendar:operation', Date.now() - context.startTime); + return cached as OperationResult; + } + + // 3. Build params object (exactOptionalPropertyTypes compliance) + const params: calendar_v3.Params$Resource$... = { + calendarId: 'primary', + // Only include properties with values + }; + + if (param2) { + params.param2 = param2; + } + + // 4. Make API call + const response = await context.calendar.events.operation(params); + + // 5. Format result + const result: OperationResult = { + success: true, + data: { + // Map response to clean interface + } + }; + + // 6. Cache result + await context.cacheManager.set(cacheKey, result); + + // 7. Track performance + context.performanceMonitor.track('calendar:operation', Date.now() - context.startTime); + + // 8. Log + context.logger.info('Operation completed', { ... }); + + return result; +} +``` + +#### 2. Error Handling Pattern +```typescript +try { + const response = await context.calendar.events.insert({ ... }); + return { success: true, data: response.data }; +} catch (error) { + context.performanceMonitor.track('calendar:operation', Date.now() - context.startTime, true); + context.logger.error('Operation failed', { error, operation: 'operationName' }); + + return { + success: false, + error: { + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + } + }; +} +``` + +#### 3. Type Imports +```typescript +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { + CreateEventOptions, + CreateEventResult, +} from './types.js'; +``` + +### Environment Variables + +#### Required +```bash +# Existing +GDRIVE_TOKEN_ENCRYPTION_KEY="..." # Token encryption key +GDRIVE_OAUTH_PATH="..." # OAuth keys file path +REDIS_URL="redis://localhost:6379" # Redis cache + +# New for Calendar +PAI_CONTACTS_PATH="/Users/ossieirondi/PAI/.claude/skills/CORE/USER/CONTACTS.md" +``` + +#### .env.example +Add to project root: +```bash +# Google Calendar Integration +PAI_CONTACTS_PATH=/Users/ossieirondi/PAI/.claude/skills/CORE/USER/CONTACTS.md +``` + +### Documentation Updates + +#### 1. README.md +Add calendar section: +```markdown +## Google Calendar Integration (v3.3.0+) + +The MCP server provides comprehensive calendar operations: +- **Calendar Management:** List calendars, view calendar details +- **Event CRUD:** Create, read, update, delete events +- **Advanced Features:** Recurring events (RRULE), timezone support, Google Meet links +- **PAI Integration:** Invite contacts by name (resolves from PAI contact list) + +### Setup +1. Re-authenticate to grant calendar scopes: + ```bash + node ./dist/index.js auth + ``` + +2. Set PAI contacts path (optional): + ```bash + export PAI_CONTACTS_PATH=/path/to/PAI/.claude/skills/CORE/USER/CONTACTS.md + ``` + +3. Use calendar operations: + ```typescript + // Create event with PAI contacts + calendar.createEvent({ + summary: "Team Meeting", + start: { dateTime: "2026-01-09T14:00:00-06:00" }, + end: { dateTime: "2026-01-09T15:00:00-06:00" }, + attendees: ["Mary", "Kelvin"] // Resolved from PAI contacts + }) + ``` +``` + +#### 2. CHANGELOG.md +Add v3.3.0 entry: +```markdown +## [3.3.0] - 2026-01-08 + +### Added +- **Google Calendar Integration** - Full calendar functionality (#XX) + - Calendar management (listCalendars, getCalendar) + - Event CRUD operations (create, read, update, delete) + - Recurring events with RFC 5545 RRULE support + - Multi-timezone support with IANA timezone names + - Google Meet conference link auto-generation + - PAI contact name resolution (e.g., "Mary" โ†’ "findsbymary@gmail.com") + - Quick Add (natural language event creation) + - FreeBusy queries (availability checking) + - Conflict detection with warnings + - Redis caching for read operations (<500ms cached reads) + - Comprehensive test coverage (66+ unit tests, 3 integration test suites) + +### Changed +- Added OAuth scopes: `calendar.readonly`, `calendar.events` +- Updated authentication flow to include calendar permissions + +### Migration +- Users must re-authenticate to grant calendar scopes: `node ./dist/index.js auth` +- Optional: Set `PAI_CONTACTS_PATH` environment variable for contact resolution +``` + +## Summary + +### Key Decisions Made + +1. **Architecture:** Operation-based progressive disclosure (matching Gmail/Drive patterns) +2. **Scope:** Full scheduling MVP (CRUD + recurring + timezones + Meet links), excluding calendar ACLs +3. **Contact Resolution:** Smart PAI contact integration via environment variable +4. **Performance:** < 500ms cached reads, < 1s writes, Redis caching with 5min TTL +5. **Error Handling:** Clear errors with descriptive messages, no auto-retry +6. **Conflict Detection:** Warn but allow overlapping events +7. **Testing:** >80% coverage, matching Gmail test patterns +8. **Security:** Follow existing OAuth pattern, accept unencrypted Redis cache (short TTL mitigation) +9. **Integration:** Gmail thread linking, Drive attachments, Linear task sync (future enhancements) +10. **Tech Debt:** Hard-coded PAI path (env var), no offline support, simple conflicts + +### Implementation Checklist + +- [ ] **Phase 1: Core Module Setup** + - [ ] Create `src/modules/calendar/` directory structure + - [ ] Implement `types.ts` with all interfaces + - [ ] Implement `contacts.ts` for PAI contact resolution + - [ ] Add CalendarContext to `src/modules/types.ts` + +- [ ] **Phase 2: Calendar Operations** + - [ ] Implement `list.ts` (listCalendars, listEvents) + - [ ] Implement `read.ts` (getCalendar, getEvent) + - [ ] Implement `create.ts` (createEvent, quickAdd) + - [ ] Implement `update.ts` (updateEvent) + - [ ] Implement `delete.ts` (deleteEvent) + - [ ] Implement `freebusy.ts` (checkFreeBusy) + - [ ] Create `index.ts` with public exports + +- [ ] **Phase 3: Server Integration** + - [ ] Modify `index.ts` to add calendar API initialization + - [ ] Add calendar tool to ListToolsRequestSchema handler + - [ ] Add calendar case to CallToolRequestSchema handler + - [ ] Add calendar scopes to authenticate() call + - [ ] Update `src/tools/listTools.ts` with calendar operations + +- [ ] **Phase 4: Testing** + - [ ] Write unit tests for each operation (66+ tests) + - [ ] Write integration test suites (3 suites) + - [ ] Write edge case tests (recurring, timezones, conflicts) + - [ ] Achieve >80% test coverage + - [ ] Run full test suite: `npm run test:coverage` + +- [ ] **Phase 5: Documentation** + - [ ] Update README.md with calendar section + - [ ] Update CHANGELOG.md with v3.3.0 entry + - [ ] Add `.env.example` entry for PAI_CONTACTS_PATH + - [ ] Create example usage snippets + +- [ ] **Phase 6: Manual Testing** + - [ ] Re-authenticate with calendar scopes + - [ ] Create event with PAI contact names + - [ ] List upcoming events for the week + - [ ] Update event details + - [ ] Delete event + - [ ] Verify Redis caching behavior + - [ ] Test conflict detection warnings + +- [ ] **Phase 7: Final Validation** + - [ ] All acceptance criteria passing + - [ ] Performance metrics met (<500ms reads, <1s writes) + - [ ] No regressions in existing modules + - [ ] Code review checklist complete + - [ ] Ready for merge + +--- + +**Open Questions:** 0 (all resolved via interview) + +**Estimated Effort:** +- Core implementation: ~1,270 lines of new code +- Test implementation: ~3,330 lines of test code +- Documentation updates: ~200 lines +- **Total:** ~4,800 lines + +**Timeline Estimate:** +- Phase 1-2 (Core module): 1-2 days +- Phase 3 (Server integration): 0.5 days +- Phase 4 (Testing): 1-2 days +- Phase 5-7 (Docs + validation): 0.5-1 day +- **Total:** 3-5 days for full implementation + +**Next Steps:** +1. Review and approve this specification +2. Begin Phase 1: Core module setup +3. Iterate through implementation phases +4. Manual testing and validation +5. Merge and release v3.3.0 diff --git a/src/modules/calendar/__tests__/contacts.test.ts b/src/modules/calendar/__tests__/contacts.test.ts new file mode 100644 index 0000000..c5b3e82 --- /dev/null +++ b/src/modules/calendar/__tests__/contacts.test.ts @@ -0,0 +1,350 @@ +/** + * Tests for PAI contact resolution + * + * Following TDD approach per spec: + * - Test contacts resolution with first names + * - Test raw email addresses + * - Test mixed inputs + * - Test unknown contacts + * - Test case-insensitive matching + * - Test missing contacts file + * - Test parse errors + * - Test duplicate names + */ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import type { Logger } from 'winston'; +import { resolveContacts, parseContactsFile } from '../contacts.js'; + +// Mock fs module +jest.mock('fs/promises'); + +const mockReadFile = fs.readFile as jest.MockedFunction; + +// Mock logger +const mockLogger: Logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +} as unknown as Logger; + +// Test contacts path used when contact resolution is needed +const TEST_CONTACTS_PATH = '/test/contacts.md'; + +describe('resolveContacts', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Set test contacts path by default - tests that need "no path" behavior will clear it + process.env.PAI_CONTACTS_PATH = TEST_CONTACTS_PATH; + }); + + afterEach(() => { + delete process.env.PAI_CONTACTS_PATH; + }); + + describe('First name resolution', () => { + it('should resolve first name to email address', async () => { + // Setup: Mock contacts file with Mary + const contactsContent = `- **Mary** [Wife/Life Partner] - findsbymary@gmail.com`; + mockReadFile.mockResolvedValue(contactsContent); + + // Execute + const result = await resolveContacts(['Mary'], mockLogger); + + // Verify + expect(result).toHaveLength(1); + expect(result[0]!).toEqual({ + email: 'findsbymary@gmail.com', + displayName: 'Mary', + role: 'Wife/Life Partner', + }); + }); + + it('should resolve multiple first names', async () => { + // Setup + const contactsContent = ` +- **Mary** [Wife/Life Partner] - findsbymary@gmail.com +- **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com +- **Giauna** [Daughter] - giauna@example.com + `.trim(); + mockReadFile.mockResolvedValue(contactsContent); + + // Execute + const result = await resolveContacts(['Mary', 'Kelvin'], mockLogger); + + // Verify + expect(result).toHaveLength(2); + expect(result[0]!.email).toBe('findsbymary@gmail.com'); + expect(result[1]!.email).toBe('mmesomakelvin@gmail.com'); + }); + + it('should handle case-insensitive matching', async () => { + // Setup + const contactsContent = `- **Mary** [Wife/Life Partner] - findsbymary@gmail.com`; + mockReadFile.mockResolvedValue(contactsContent); + + // Execute: Test lowercase, uppercase, mixed case + const result1 = await resolveContacts(['mary'], mockLogger); + const result2 = await resolveContacts(['MARY'], mockLogger); + const result3 = await resolveContacts(['MaRy'], mockLogger); + + // Verify: All should resolve to same contact + expect(result1[0]!.email).toBe('findsbymary@gmail.com'); + expect(result2[0]!.email).toBe('findsbymary@gmail.com'); + expect(result3[0]!.email).toBe('findsbymary@gmail.com'); + }); + }); + + describe('Raw email addresses', () => { + it('should pass through valid email addresses unchanged', async () => { + // Setup: Empty contacts file + mockReadFile.mockResolvedValue(''); + + // Execute + const result = await resolveContacts(['user@example.com'], mockLogger); + + // Verify + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + email: 'user@example.com', + displayName: undefined, + role: undefined, + }); + }); + + it('should handle multiple raw emails', async () => { + // Setup + mockReadFile.mockResolvedValue(''); + + // Execute + const result = await resolveContacts( + ['alice@example.com', 'bob@company.org', 'charlie@test.io'], + mockLogger + ); + + // Verify + expect(result).toHaveLength(3); + expect(result[0]!.email).toBe('alice@example.com'); + expect(result[1]!.email).toBe('bob@company.org'); + expect(result[2]!.email).toBe('charlie@test.io'); + }); + }); + + describe('Mixed inputs', () => { + it('should handle mix of names and emails', async () => { + // Setup + const contactsContent = ` +- **Mary** [Wife/Life Partner] - findsbymary@gmail.com +- **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com + `.trim(); + mockReadFile.mockResolvedValue(contactsContent); + + // Execute + const result = await resolveContacts( + ['Mary', 'external@company.com', 'Kelvin', 'another@test.org'], + mockLogger + ); + + // Verify + expect(result).toHaveLength(4); + expect(result[0]!.email).toBe('findsbymary@gmail.com'); + expect(result[1]!.email).toBe('external@company.com'); + expect(result[2]!.email).toBe('mmesomakelvin@gmail.com'); + expect(result[3]!.email).toBe('another@test.org'); + }); + }); + + describe('Unknown contacts', () => { + it('should throw error for unknown contact name', async () => { + // Setup + const contactsContent = ` +- **Mary** [Wife/Life Partner] - findsbymary@gmail.com +- **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com + `.trim(); + mockReadFile.mockResolvedValue(contactsContent); + + // Execute & Verify + await expect( + resolveContacts(['Bob'], mockLogger) + ).rejects.toThrow(/Contact 'Bob' not found in PAI contact list/); + }); + + it('should list available contacts in error message', async () => { + // Setup + const contactsContent = ` +- **Mary** [Wife/Life Partner] - findsbymary@gmail.com +- **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com +- **Giauna** [Daughter] - giauna@example.com + `.trim(); + mockReadFile.mockResolvedValue(contactsContent); + + // Execute & Verify: Contacts are sorted alphabetically + await expect( + resolveContacts(['UnknownPerson'], mockLogger) + ).rejects.toThrow(/Available contacts: Giauna, Kelvin, Mary/); + }); + }); + + describe('Missing contacts file', () => { + it('should fallback to treating all inputs as emails when file missing', async () => { + // Setup: Simulate file not found error + mockReadFile.mockRejectedValue( + Object.assign(new Error('ENOENT: no such file or directory'), { + code: 'ENOENT', + }) + ); + + // Execute + const result = await resolveContacts( + ['Mary', 'user@example.com'], + mockLogger + ); + + // Verify: Both treated as raw emails when contacts file unavailable + expect(result).toHaveLength(2); + expect(result[0]!.email).toBe('Mary'); + expect(result[1]!.email).toBe('user@example.com'); + + // Verify warning was logged + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('PAI contacts file not found'), + expect.any(Object) + ); + }); + }); + + describe('Environment variable', () => { + it('should use PAI_CONTACTS_PATH environment variable if set', async () => { + // Setup + process.env.PAI_CONTACTS_PATH = '/custom/path/contacts.md'; + const contactsContent = `- **Mary** [Wife] - mary@example.com`; + mockReadFile.mockResolvedValue(contactsContent); + + // Execute + await resolveContacts(['Mary'], mockLogger); + + // Verify: Should read from custom path + expect(mockReadFile).toHaveBeenCalledWith('/custom/path/contacts.md', 'utf-8'); + }); + + it('should treat all inputs as raw emails when PAI_CONTACTS_PATH not set (fallback mode)', async () => { + // Setup: Clear PAI_CONTACTS_PATH - new behavior is "raw emails only" fallback mode + // Since DEFAULT_CONTACTS_PATH is null, no file should be read + delete process.env.PAI_CONTACTS_PATH; + + // Execute + const result = await resolveContacts(['Mary', 'test@example.com'], mockLogger); + + // Verify: Should NOT read any file (no default path) + expect(mockReadFile).not.toHaveBeenCalled(); + + // Verify: Both inputs treated as raw (fallback mode - no validation) + // "Mary" becomes {email: "Mary"} even though it's not a valid email format + expect(result).toHaveLength(2); + expect(result[0]!.email).toBe('Mary'); + expect(result[1]!.email).toBe('test@example.com'); + + // Verify warning logged for non-email input + expect(mockLogger.info).toHaveBeenCalledWith('PAI_CONTACTS_PATH not set; treating all inputs as raw emails'); + }); + }); +}); + +describe('parseContactsFile', () => { + it('should parse standard contact format', () => { + // Setup + const content = ` +# My Contacts + +- **Mary** [Wife/Life Partner] - findsbymary@gmail.com +- **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com + `.trim(); + + // Execute + const contacts = parseContactsFile(content); + + // Verify + expect(contacts).toHaveLength(2); + expect(contacts[0]!).toEqual({ + name: 'mary', + displayName: 'Mary', + email: 'findsbymary@gmail.com', + role: 'Wife/Life Partner', + }); + expect(contacts[1]!).toEqual({ + name: 'kelvin', + displayName: 'Kelvin', + email: 'mmesomakelvin@gmail.com', + role: 'Junior Developer', + }); + }); + + it('should handle contacts without roles', () => { + // Setup + const content = `- **Alice** - alice@example.com`; + + // Execute + const contacts = parseContactsFile(content); + + // Verify + expect(contacts).toHaveLength(1); + expect(contacts[0]!).toEqual({ + name: 'alice', + displayName: 'Alice', + email: 'alice@example.com', + role: undefined, + }); + }); + + it('should skip invalid lines', () => { + // Setup: Mix of valid and invalid lines + const content = ` +- **Mary** [Wife] - mary@example.com +This is not a valid contact line +- Invalid format +- **Bob** [Friend] - bob@example.com + `.trim(); + + // Execute + const contacts = parseContactsFile(content); + + // Verify: Only valid lines parsed + expect(contacts).toHaveLength(2); + expect(contacts[0]!.displayName).toBe('Mary'); + expect(contacts[1]!.displayName).toBe('Bob'); + }); + + it('should handle duplicate names by keeping first occurrence', () => { + // Setup + const content = ` +- **Mary** [Wife] - mary1@example.com +- **Mary** [Friend] - mary2@example.com + `.trim(); + + // Execute + const contacts = parseContactsFile(content); + + // Verify: Both entries present (deduplication happens in resolveContacts) + expect(contacts).toHaveLength(2); + expect(contacts[0]!.email).toBe('mary1@example.com'); + expect(contacts[1]!.email).toBe('mary2@example.com'); + }); + + it('should handle empty content', () => { + // Execute + const contacts = parseContactsFile(''); + + // Verify + expect(contacts).toHaveLength(0); + }); + + it('should handle whitespace-only content', () => { + // Execute + const contacts = parseContactsFile(' \n\n \t \n '); + + // Verify + expect(contacts).toHaveLength(0); + }); +}); diff --git a/src/modules/calendar/__tests__/freebusy.test.ts b/src/modules/calendar/__tests__/freebusy.test.ts new file mode 100644 index 0000000..be5bce9 --- /dev/null +++ b/src/modules/calendar/__tests__/freebusy.test.ts @@ -0,0 +1,295 @@ +/** + * Tests for calendar freebusy operations + * Following TDD approach - write failing tests first + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../../types.js'; +import { checkFreeBusy } from '../freebusy.js'; + +describe('checkFreeBusy', () => { + let mockContext: CalendarContext; + let mockCalendarApi: jest.Mocked; + + beforeEach(() => { + // Mock calendar API + mockCalendarApi = { + freebusy: { + query: jest.fn(), + }, + } as unknown as jest.Mocked; + + // Mock context + mockContext = { + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + calendar: mockCalendarApi, + cacheManager: { + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + get: jest.fn().mockResolvedValue(null), + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + set: jest.fn().mockResolvedValue(undefined), + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + invalidate: jest.fn().mockResolvedValue(undefined), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + } as unknown as CalendarContext; + }); + + test('checks free/busy for a single calendar', async () => { + const mockResponse = { + data: { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + calendars: { + 'primary': { + busy: [ + { + start: '2026-01-09T10:00:00Z', + end: '2026-01-09T11:00:00Z', + }, + { + start: '2026-01-09T14:00:00Z', + end: '2026-01-09T15:30:00Z', + }, + ], + }, + }, + }, + }; + + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockResolvedValue(mockResponse); + + const result = await checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + items: [{ id: 'primary' }], + }, + mockContext + ); + + expect(result.timeMin).toBe('2026-01-09T00:00:00Z'); + expect(result.timeMax).toBe('2026-01-09T23:59:59Z'); + expect(result.calendars['primary']).toBeDefined(); + expect(result.calendars['primary']!.busy).toHaveLength(2); + expect(result.calendars['primary']!.busy[0]).toMatchObject({ + start: '2026-01-09T10:00:00Z', + end: '2026-01-09T11:00:00Z', + }); + }); + + test('checks free/busy for multiple calendars', async () => { + const mockResponse = { + data: { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + calendars: { + 'primary': { + busy: [ + { + start: '2026-01-09T10:00:00Z', + end: '2026-01-09T11:00:00Z', + }, + ], + }, + 'test@example.com': { + busy: [ + { + start: '2026-01-09T13:00:00Z', + end: '2026-01-09T14:00:00Z', + }, + ], + }, + }, + }, + }; + + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockResolvedValue(mockResponse); + + const result = await checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + items: [{ id: 'primary' }, { id: 'test@example.com' }], + }, + mockContext + ); + + expect(Object.keys(result.calendars)).toHaveLength(2); + expect(result.calendars['primary']!.busy).toHaveLength(1); + expect(result.calendars['test@example.com']!.busy).toHaveLength(1); + }); + + test('returns empty busy array when calendar is free', async () => { + const mockResponse = { + data: { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + calendars: { + 'primary': { + busy: [], + }, + }, + }, + }; + + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockResolvedValue(mockResponse); + + const result = await checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + items: [{ id: 'primary' }], + }, + mockContext + ); + + expect(result.calendars['primary']!.busy).toHaveLength(0); + }); + + test('includes errors when calendar access fails', async () => { + const mockResponse = { + data: { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + calendars: { + 'restricted@example.com': { + busy: [], + errors: [ + { + domain: 'calendar', + reason: 'notFound', + }, + ], + }, + }, + }, + }; + + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockResolvedValue(mockResponse); + + const result = await checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + items: [{ id: 'restricted@example.com' }], + }, + mockContext + ); + + expect(result.calendars['restricted@example.com']!.errors).toBeDefined(); + expect(result.calendars['restricted@example.com']!.errors).toHaveLength(1); + expect(result.calendars['restricted@example.com']!.errors?.[0]).toMatchObject({ + domain: 'calendar', + reason: 'notFound', + }); + }); + + test('caches the result', async () => { + const mockResponse = { + data: { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + calendars: { + 'primary': { + busy: [], + }, + }, + }, + }; + + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockResolvedValue(mockResponse); + + await checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + items: [{ id: 'primary' }], + }, + mockContext + ); + + // Verify cache was set + // Note: Current CacheManager doesn't support per-operation TTL + // TODO: Verify 60s TTL when CacheManager supports it (per spec requirement) + expect(mockContext.cacheManager.set).toHaveBeenCalledWith( + expect.stringContaining('calendar:checkFreeBusy:'), + expect.any(Object) + ); + }); + + test('supports optional timezone parameter', async () => { + const mockResponse = { + data: { + timeMin: '2026-01-09T00:00:00-06:00', + timeMax: '2026-01-09T23:59:59-06:00', + calendars: { + 'primary': { + busy: [], + }, + }, + }, + }; + + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockResolvedValue(mockResponse); + + const result = await checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00-06:00', + timeMax: '2026-01-09T23:59:59-06:00', + items: [{ id: 'primary' }], + timeZone: 'America/Chicago', + }, + mockContext + ); + + expect(mockCalendarApi.freebusy.query).toHaveBeenCalledWith({ + requestBody: { + timeMin: '2026-01-09T00:00:00-06:00', + timeMax: '2026-01-09T23:59:59-06:00', + items: [{ id: 'primary' }], + timeZone: 'America/Chicago', + }, + }); + expect(result.calendars['primary']).toBeDefined(); + }); + + test('handles API errors gracefully', async () => { + const error = new Error('API Error'); + // @ts-expect-error - Mock typing with exactOptionalPropertyTypes + (mockCalendarApi.freebusy.query as jest.Mock).mockRejectedValue(error); + + await expect( + checkFreeBusy( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + items: [{ id: 'primary' }], + }, + mockContext + ) + ).rejects.toThrow('API Error'); + + expect(mockContext.logger.error).toHaveBeenCalledWith( + 'Failed to check free/busy', + expect.objectContaining({ + error: expect.any(Error), + }) + ); + }); +}); diff --git a/src/modules/calendar/__tests__/list.test.ts b/src/modules/calendar/__tests__/list.test.ts new file mode 100644 index 0000000..74b190f --- /dev/null +++ b/src/modules/calendar/__tests__/list.test.ts @@ -0,0 +1,479 @@ +/** + * Tests for calendar list operations + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { listCalendars, listEvents } from '../list.js'; + +describe('listCalendars', () => { + let mockContext: any; + let mockCalendarApi: any; + + beforeEach(() => { + // Mock calendar API + mockCalendarApi = { + calendarList: { + list: jest.fn(), + }, + }; + + // Mock context + mockContext = { + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + calendar: mockCalendarApi, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('lists calendars with default options', async () => { + const mockResponse = { + data: { + items: [ + { + id: 'primary', + summary: 'Primary Calendar', + timeZone: 'America/Chicago', + primary: true, + accessRole: 'owner', + }, + { + id: 'test@example.com', + summary: 'Test Calendar', + description: 'Test description', + timeZone: 'America/New_York', + accessRole: 'reader', + }, + ], + }, + }; + + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + const result = await listCalendars({}, mockContext); + + expect(result.calendars).toHaveLength(2); + expect(result.calendars[0]).toMatchObject({ + id: 'primary', + summary: 'Primary Calendar', + timeZone: 'America/Chicago', + primary: true, + accessRole: 'owner', + }); + expect(result.calendars[1]).toMatchObject({ + id: 'test@example.com', + summary: 'Test Calendar', + description: 'Test description', + timeZone: 'America/New_York', + accessRole: 'reader', + }); + }); + + test('limits maxResults to API maximum', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + await listCalendars({ maxResults: 1000 }, mockContext); + + expect(mockCalendarApi.calendarList.list).toHaveBeenCalledWith( + expect.objectContaining({ + maxResults: 250, // Calendar API limit + }) + ); + }); + + test('includes pageToken when provided', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + await listCalendars({ pageToken: 'token123' }, mockContext); + + expect(mockCalendarApi.calendarList.list).toHaveBeenCalledWith( + expect.objectContaining({ + pageToken: 'token123', + }) + ); + }); + + test('returns nextPageToken when available', async () => { + const mockResponse = { + data: { + items: [{ id: 'cal1', summary: 'Calendar 1' }], + nextPageToken: 'next-token', + }, + }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + const result = await listCalendars({}, mockContext); + + expect(result.nextPageToken).toBe('next-token'); + }); + + test('does not include nextPageToken when not available', async () => { + const mockResponse = { + data: { + items: [{ id: 'cal1', summary: 'Calendar 1' }], + }, + }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + const result = await listCalendars({}, mockContext); + + expect(result.nextPageToken).toBeUndefined(); + }); + + test('uses cache when available', async () => { + const cachedResult = { + calendars: [{ id: 'cached', summary: 'Cached Calendar' }], + }; + mockContext.cacheManager.get = jest.fn(() => Promise.resolve(cachedResult)); + + const result = await listCalendars({}, mockContext); + + expect(result).toEqual(cachedResult); + expect(mockCalendarApi.calendarList.list).not.toHaveBeenCalled(); + expect(mockContext.performanceMonitor.track).toHaveBeenCalledWith( + 'calendar:listCalendars', + expect.any(Number) + ); + }); + + test('caches result after API call', async () => { + const mockResponse = { + data: { + items: [{ id: 'cal1', summary: 'Calendar 1' }], + }, + }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + await listCalendars({}, mockContext); + + expect(mockContext.cacheManager.set).toHaveBeenCalledWith( + expect.stringContaining('calendar:listCalendars:'), + expect.objectContaining({ + calendars: expect.any(Array), + }) + ); + }); + + test('tracks performance metrics', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + await listCalendars({}, mockContext); + + expect(mockContext.performanceMonitor.track).toHaveBeenCalledWith( + 'calendar:listCalendars', + expect.any(Number) + ); + }); + + test('logs calendar list info', async () => { + const mockResponse = { + data: { + items: [ + { id: 'cal1', summary: 'Calendar 1' }, + { id: 'cal2', summary: 'Calendar 2' }, + ], + }, + }; + mockCalendarApi.calendarList.list.mockResolvedValue(mockResponse); + + await listCalendars({}, mockContext); + + expect(mockContext.logger.info).toHaveBeenCalledWith( + 'Listed calendars', + expect.objectContaining({ + count: 2, + }) + ); + }); +}); + +describe('listEvents', () => { + let mockContext: any; + let mockCalendarApi: any; + + beforeEach(() => { + // Mock calendar API + mockCalendarApi = { + events: { + list: jest.fn(), + }, + }; + + // Mock context + mockContext = { + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + calendar: mockCalendarApi, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('lists events with default calendarId', async () => { + const mockResponse = { + data: { + items: [ + { + id: 'event1', + summary: 'Test Event', + start: { dateTime: '2026-01-09T14:00:00-06:00' }, + end: { dateTime: '2026-01-09T15:00:00-06:00' }, + status: 'confirmed', + }, + ], + timeZone: 'America/Chicago', + }, + }; + + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + const result = await listEvents({}, mockContext); + + expect(mockCalendarApi.events.list).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'primary', + }) + ); + expect(result.events).toHaveLength(1); + expect(result.timeZone).toBe('America/Chicago'); + }); + + test('includes time range filters when provided', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents( + { + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + }, + mockContext + ); + + expect(mockCalendarApi.events.list).toHaveBeenCalledWith( + expect.objectContaining({ + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + }) + ); + }); + + test('defaults singleEvents to true', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({}, mockContext); + + expect(mockCalendarApi.events.list).toHaveBeenCalledWith( + expect.objectContaining({ + singleEvents: true, + }) + ); + }); + + test('respects custom singleEvents setting', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({ singleEvents: false }, mockContext); + + expect(mockCalendarApi.events.list).toHaveBeenCalledWith( + expect.objectContaining({ + singleEvents: false, + }) + ); + }); + + test('includes orderBy when provided', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({ orderBy: 'startTime' }, mockContext); + + expect(mockCalendarApi.events.list).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: 'startTime', + }) + ); + }); + + test('limits maxResults to API maximum', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({ maxResults: 5000 }, mockContext); + + expect(mockCalendarApi.events.list).toHaveBeenCalledWith( + expect.objectContaining({ + maxResults: 2500, // Calendar API limit + }) + ); + }); + + test('parses event attendee count', async () => { + const mockResponse = { + data: { + items: [ + { + id: 'event1', + summary: 'Team Meeting', + start: { dateTime: '2026-01-09T14:00:00-06:00' }, + end: { dateTime: '2026-01-09T15:00:00-06:00' }, + attendees: [ + { email: 'user1@example.com' }, + { email: 'user2@example.com' }, + { email: 'user3@example.com' }, + ], + }, + ], + }, + }; + + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + const result = await listEvents({}, mockContext); + + expect(result.events[0]?.attendeeCount).toBe(3); + }); + + test('uses cache when available', async () => { + const cachedResult = { + events: [{ id: 'cached-event', summary: 'Cached Event' }], + }; + mockContext.cacheManager.get = jest.fn(() => Promise.resolve(cachedResult)); + + const result = await listEvents({}, mockContext); + + expect(result).toEqual(cachedResult); + expect(mockCalendarApi.events.list).not.toHaveBeenCalled(); + }); + + test('caches result after API call', async () => { + const mockResponse = { + data: { + items: [{ id: 'event1', summary: 'Event 1' }], + }, + }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({}, mockContext); + + expect(mockContext.cacheManager.set).toHaveBeenCalledWith( + expect.stringContaining('calendar:listEvents:'), + expect.any(Object) + ); + }); + + test('uses calendarId segment in cache key for invalidation', async () => { + const mockResponse = { + data: { + items: [{ id: 'event1', summary: 'Event 1' }], + }, + }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents( + { + calendarId: 'cal-123', + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + maxResults: 25, + pageToken: 'page-2', + singleEvents: false, + orderBy: 'startTime', + showDeleted: true, + timeZone: 'UTC', + }, + mockContext + ); + + const expectedKey = `calendar:listEvents:cal-123:${JSON.stringify({ + timeMin: '2026-01-09T00:00:00Z', + timeMax: '2026-01-09T23:59:59Z', + maxResults: 25, + pageToken: 'page-2', + singleEvents: false, + orderBy: 'startTime', + showDeleted: true, + timeZone: 'UTC', + })}`; + + expect(mockContext.cacheManager.set).toHaveBeenCalledWith( + expectedKey, + expect.any(Object) + ); + }); + + test('handles pagination with nextPageToken', async () => { + const mockResponse = { + data: { + items: [{ id: 'event1', summary: 'Event 1' }], + nextPageToken: 'next-page-token', + }, + }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + const result = await listEvents({}, mockContext); + + expect(result.nextPageToken).toBe('next-page-token'); + }); + + test('tracks performance metrics', async () => { + const mockResponse = { data: { items: [] } }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({}, mockContext); + + expect(mockContext.performanceMonitor.track).toHaveBeenCalledWith( + 'calendar:listEvents', + expect.any(Number) + ); + }); + + test('logs event list info', async () => { + const mockResponse = { + data: { + items: [ + { id: 'event1', summary: 'Event 1' }, + { id: 'event2', summary: 'Event 2' }, + ], + }, + }; + mockCalendarApi.events.list.mockResolvedValue(mockResponse); + + await listEvents({}, mockContext); + + expect(mockContext.logger.info).toHaveBeenCalledWith( + 'Listed events', + expect.objectContaining({ + count: 2, + }) + ); + }); +}); diff --git a/src/modules/calendar/__tests__/read.test.ts b/src/modules/calendar/__tests__/read.test.ts new file mode 100644 index 0000000..b94a8b2 --- /dev/null +++ b/src/modules/calendar/__tests__/read.test.ts @@ -0,0 +1,432 @@ +/** + * Tests for calendar read operations + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { getCalendar, getEvent } from '../read.js'; + +describe('getCalendar', () => { + let mockContext: any; + let mockCalendarApi: any; + + beforeEach(() => { + // Mock calendar API + mockCalendarApi = { + calendars: { + get: jest.fn(), + }, + }; + + // Mock context + mockContext = { + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + calendar: mockCalendarApi, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('gets calendar by ID', async () => { + const mockResponse = { + data: { + id: 'primary', + summary: 'Primary Calendar', + description: 'My personal calendar', + location: 'Chicago, IL', + timeZone: 'America/Chicago', + conferenceProperties: { + allowedConferenceSolutionTypes: ['hangoutsMeet'], + }, + }, + }; + + mockCalendarApi.calendars.get.mockResolvedValue(mockResponse); + + const result = await getCalendar({ calendarId: 'primary' }, mockContext); + + expect(result.id).toBe('primary'); + expect(result.summary).toBe('Primary Calendar'); + expect(result.description).toBe('My personal calendar'); + expect(result.location).toBe('Chicago, IL'); + expect(result.timeZone).toBe('America/Chicago'); + expect(result.conferenceProperties?.allowedConferenceSolutionTypes).toEqual(['hangoutsMeet']); + }); + + test('uses cache when available', async () => { + const cachedResult = { + id: 'cached-cal', + summary: 'Cached Calendar', + timeZone: 'UTC', + }; + mockContext.cacheManager.get = jest.fn(() => Promise.resolve(cachedResult)); + + const result = await getCalendar({ calendarId: 'cached-cal' }, mockContext); + + expect(result).toEqual(cachedResult); + expect(mockCalendarApi.calendars.get).not.toHaveBeenCalled(); + expect(mockContext.performanceMonitor.track).toHaveBeenCalledWith( + 'calendar:getCalendar', + expect.any(Number) + ); + }); + + test('caches result after API call', async () => { + const mockResponse = { + data: { + id: 'cal1', + summary: 'Calendar 1', + timeZone: 'America/Chicago', + }, + }; + mockCalendarApi.calendars.get.mockResolvedValue(mockResponse); + + await getCalendar({ calendarId: 'cal1' }, mockContext); + + expect(mockContext.cacheManager.set).toHaveBeenCalledWith( + expect.stringContaining('calendar:getCalendar:cal1'), + expect.objectContaining({ + id: 'cal1', + summary: 'Calendar 1', + }) + ); + }); + + test('tracks performance metrics', async () => { + const mockResponse = { + data: { + id: 'cal1', + summary: 'Calendar 1', + timeZone: 'UTC', + }, + }; + mockCalendarApi.calendars.get.mockResolvedValue(mockResponse); + + await getCalendar({ calendarId: 'cal1' }, mockContext); + + expect(mockContext.performanceMonitor.track).toHaveBeenCalledWith( + 'calendar:getCalendar', + expect.any(Number) + ); + }); + + test('logs calendar retrieval', async () => { + const mockResponse = { + data: { + id: 'primary', + summary: 'Primary Calendar', + timeZone: 'America/Chicago', + }, + }; + mockCalendarApi.calendars.get.mockResolvedValue(mockResponse); + + await getCalendar({ calendarId: 'primary' }, mockContext); + + expect(mockContext.logger.info).toHaveBeenCalledWith( + 'Retrieved calendar', + expect.objectContaining({ + calendarId: 'primary', + summary: 'Primary Calendar', + }) + ); + }); +}); + +describe('getEvent', () => { + let mockContext: any; + let mockCalendarApi: any; + + beforeEach(() => { + // Mock calendar API + mockCalendarApi = { + events: { + get: jest.fn(), + }, + }; + + // Mock context + mockContext = { + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + calendar: mockCalendarApi, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('gets event by ID with default calendarId', async () => { + const mockResponse = { + data: { + id: 'event123', + status: 'confirmed', + htmlLink: 'https://calendar.google.com/event?eid=event123', + created: '2026-01-08T10:00:00Z', + updated: '2026-01-08T10:00:00Z', + summary: 'Team Meeting', + description: 'Weekly sync', + location: 'Conference Room A', + start: { + dateTime: '2026-01-09T14:00:00-06:00', + timeZone: 'America/Chicago', + }, + end: { + dateTime: '2026-01-09T15:00:00-06:00', + timeZone: 'America/Chicago', + }, + creator: { + email: 'creator@example.com', + displayName: 'Creator Name', + }, + organizer: { + email: 'organizer@example.com', + displayName: 'Organizer Name', + }, + attendees: [ + { + email: 'user1@example.com', + displayName: 'User One', + responseStatus: 'accepted', + organizer: false, + }, + { + email: 'user2@example.com', + displayName: 'User Two', + responseStatus: 'needsAction', + optional: true, + }, + ], + }, + }; + + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + const result = await getEvent({ eventId: 'event123' }, mockContext); + + expect(mockCalendarApi.events.get).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'primary', + eventId: 'event123', + }) + ); + expect(result.id).toBe('event123'); + expect(result.summary).toBe('Team Meeting'); + expect(result.attendees).toHaveLength(2); + }); + + test('uses custom calendarId when provided', async () => { + const mockResponse = { + data: { + id: 'event456', + summary: 'Custom Calendar Event', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }, + }; + + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + await getEvent({ calendarId: 'custom@example.com', eventId: 'event456' }, mockContext); + + expect(mockCalendarApi.events.get).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'custom@example.com', + eventId: 'event456', + }) + ); + }); + + test('parses attendee details correctly', async () => { + const mockResponse = { + data: { + id: 'event789', + summary: 'Event with Attendees', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + attendees: [ + { + email: 'organizer@example.com', + displayName: 'Organizer', + responseStatus: 'accepted', + organizer: true, + self: false, + }, + { + email: 'me@example.com', + displayName: 'Me', + responseStatus: 'accepted', + self: true, + }, + { + email: 'optional@example.com', + displayName: 'Optional Attendee', + responseStatus: 'tentative', + optional: true, + }, + ], + }, + }; + + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + const result = await getEvent({ eventId: 'event789' }, mockContext); + + expect(result.attendees).toHaveLength(3); + expect(result.attendees![0]).toMatchObject({ + email: 'organizer@example.com', + displayName: 'Organizer', + responseStatus: 'accepted', + organizer: true, + }); + expect(result.attendees![2]).toMatchObject({ + email: 'optional@example.com', + optional: true, + responseStatus: 'tentative', + }); + }); + + test('parses recurring event details', async () => { + const mockResponse = { + data: { + id: 'recurring-event', + summary: 'Weekly Standup', + start: { dateTime: '2026-01-13T09:00:00-06:00' }, + end: { dateTime: '2026-01-13T09:30:00-06:00' }, + recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10'], + }, + }; + + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + const result = await getEvent({ eventId: 'recurring-event' }, mockContext); + + expect(result.recurrence).toEqual(['RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10']); + }); + + test('parses conference data', async () => { + const mockResponse = { + data: { + id: 'video-call', + summary: 'Video Conference', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + hangoutLink: 'https://meet.google.com/abc-defg-hij', + conferenceData: { + entryPoints: [ + { + entryPointType: 'video', + uri: 'https://meet.google.com/abc-defg-hij', + label: 'meet.google.com/abc-defg-hij', + }, + ], + }, + }, + }; + + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + const result = await getEvent({ eventId: 'video-call' }, mockContext); + + expect(result.conferenceData).toBeDefined(); + expect(result.conferenceData?.entryPoints).toHaveLength(1); + }); + + test('uses cache when available', async () => { + const cachedResult = { + id: 'cached-event', + summary: 'Cached Event', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }; + mockContext.cacheManager.get = jest.fn(() => Promise.resolve(cachedResult)); + + const result = await getEvent({ eventId: 'cached-event' }, mockContext); + + expect(result).toEqual(cachedResult); + expect(mockCalendarApi.events.get).not.toHaveBeenCalled(); + }); + + test('caches result after API call', async () => { + const mockResponse = { + data: { + id: 'event1', + summary: 'Event 1', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }, + }; + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + await getEvent({ eventId: 'event1' }, mockContext); + + expect(mockContext.cacheManager.set).toHaveBeenCalledWith( + expect.stringContaining('calendar:getEvent:event1'), + expect.objectContaining({ + id: 'event1', + summary: 'Event 1', + }) + ); + }); + + test('tracks performance metrics', async () => { + const mockResponse = { + data: { + id: 'event1', + summary: 'Event 1', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }, + }; + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + await getEvent({ eventId: 'event1' }, mockContext); + + expect(mockContext.performanceMonitor.track).toHaveBeenCalledWith( + 'calendar:getEvent', + expect.any(Number) + ); + }); + + test('logs event retrieval', async () => { + const mockResponse = { + data: { + id: 'event1', + summary: 'Important Meeting', + start: { dateTime: '2026-01-09T14:00:00Z' }, + end: { dateTime: '2026-01-09T15:00:00Z' }, + }, + }; + mockCalendarApi.events.get.mockResolvedValue(mockResponse); + + await getEvent({ eventId: 'event1' }, mockContext); + + expect(mockContext.logger.info).toHaveBeenCalledWith( + 'Retrieved event', + expect.objectContaining({ + eventId: 'event1', + summary: 'Important Meeting', + }) + ); + }); +}); diff --git a/src/modules/calendar/contacts.ts b/src/modules/calendar/contacts.ts new file mode 100644 index 0000000..844b1c7 --- /dev/null +++ b/src/modules/calendar/contacts.ts @@ -0,0 +1,200 @@ +/** + * PAI Contact Resolution for Calendar Module + * + * Resolves contact names to email addresses from PAI contact list. + * Supports: + * - First names: "Mary" -> "findsbymary@gmail.com" + * - Raw emails: "user@example.com" -> "user@example.com" + * - Mixed: ["Mary", "user@example.com"] -> ["findsbymary@gmail.com", "user@example.com"] + */ + +import * as fs from 'fs/promises'; +import type { Logger } from 'winston'; +import type { ContactEntry, ResolvedContact } from './types.js'; + +/** + * Default PAI contacts file path + * Set PAI_CONTACTS_PATH environment variable to enable contact resolution + * Without it, all inputs are treated as raw email addresses + */ +const DEFAULT_CONTACTS_PATH: string | null = null; + +/** + * Basic email validation regex + */ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Parse PAI CONTACTS.md file format + * + * Expected format: + * - **Mary** [Wife/Life Partner] - findsbymary@gmail.com + * - **Kelvin** [Junior Developer] - mmesomakelvin@gmail.com + * + * @param content - File content to parse + * @returns Array of parsed contact entries + */ +export function parseContactsFile(content: string): ContactEntry[] { + const contacts: ContactEntry[] = []; + const lines = content.split('\n'); + + // Regex pattern: - **Name** [Role] - email@example.com + // Role is optional: - **Name** - email@example.com + const contactPattern = /^-\s+\*\*([^*]+)\*\*(?:\s+\[([^\]]+)\])?\s+-\s+(.+)$/; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = trimmed.match(contactPattern); + if (match) { + const [, displayName, role, email] = match; + if (displayName && email) { + const entry: ContactEntry = { + name: displayName.toLowerCase(), // For case-insensitive matching + displayName: displayName, + email: email.trim(), + }; + if (role) { + entry.role = role.trim(); + } + contacts.push(entry); + } + } + } + + return contacts; +} + +/** + * Load and parse contacts from PAI contacts file + * + * @param logger - Winston logger + * @returns Map of contact name (lowercase) to contact entry + */ +async function loadContacts(logger: Logger): Promise> { + const contactsPath = process.env.PAI_CONTACTS_PATH || DEFAULT_CONTACTS_PATH; + const contactsMap = new Map(); + + // If no contacts path configured, return empty map (raw emails only mode) + if (!contactsPath) { + logger.info('PAI_CONTACTS_PATH not set; treating all inputs as raw emails'); + return contactsMap; + } + + try { + const content = await fs.readFile(contactsPath, 'utf-8'); + const contacts = parseContactsFile(content); + + // Build map with lowercase names as keys + for (const contact of contacts) { + // Handle duplicates: keep first occurrence, warn about duplicates + if (contactsMap.has(contact.name)) { + logger.warn('Duplicate contact name found, using first occurrence', { + name: contact.displayName, + email1: contactsMap.get(contact.name)?.email, + email2: contact.email, + }); + } else { + contactsMap.set(contact.name, contact); + } + } + + logger.info('Loaded PAI contacts', { + path: contactsPath, + count: contactsMap.size, + }); + + return contactsMap; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.warn('PAI contacts file not found, will treat all inputs as raw emails', { + path: contactsPath, + error: (error as Error).message, + }); + return contactsMap; // Return empty map + } + + logger.error('Error loading PAI contacts file', { + path: contactsPath, + error: (error as Error).message, + }); + return contactsMap; // Return empty map on parse errors + } +} + +/** + * Resolve contact names to email addresses from PAI contact list + * + * Supports: + * - First names: "Mary" -> "findsbymary@gmail.com" + * - Raw emails: "user@example.com" -> "user@example.com" + * - Mixed: ["Mary", "user@example.com"] -> ["findsbymary@gmail.com", "user@example.com"] + * - Case-insensitive: "mary", "MARY", "Mary" all resolve to same contact + * + * @param names - Array of contact names or email addresses + * @param logger - Winston logger + * @returns Array of resolved contacts with email and metadata + * @throws Error if unknown contact name found + */ +export async function resolveContacts( + names: string[], + logger: Logger +): Promise { + const contactsMap = await loadContacts(logger); + const resolved: ResolvedContact[] = []; + + for (const name of names) { + const trimmed = name.trim(); + + // Check if it's a valid email address + if (EMAIL_REGEX.test(trimmed)) { + // Pass through raw email (no displayName or role for raw emails) + resolved.push({ + email: trimmed, + }); + continue; + } + + // Try to resolve as contact name (case-insensitive) + const lowerName = trimmed.toLowerCase(); + const contact = contactsMap.get(lowerName); + + if (contact) { + // Found in contacts + const resolved_contact: ResolvedContact = { + email: contact.email, + displayName: contact.displayName, + }; + if (contact.role) { + resolved_contact.role = contact.role; + } + resolved.push(resolved_contact); + } else { + // Unknown contact and not a valid email + if (contactsMap.size === 0) { + // No contacts loaded, treat as raw email (fallback mode) + logger.warn('No contacts loaded, treating input as raw email', { + input: trimmed, + }); + resolved.push({ + email: trimmed, + }); + } else { + // Contacts exist but this name not found - throw error + const availableContacts = Array.from(contactsMap.values()) + .map((c) => c.displayName) + .sort() + .join(', '); + + throw new Error( + `Contact '${trimmed}' not found in PAI contact list. Available contacts: ${availableContacts}` + ); + } + } + } + + return resolved; +} diff --git a/src/modules/calendar/create.ts b/src/modules/calendar/create.ts new file mode 100644 index 0000000..0bf1c20 --- /dev/null +++ b/src/modules/calendar/create.ts @@ -0,0 +1,557 @@ +/** + * Calendar create operations - createEvent and quickAdd + */ + +import { randomUUID } from 'crypto'; +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { + CreateEventOptions, + EventResult, + QuickAddOptions, + Attendee, +} from './types.js'; +import { resolveContacts } from './contacts.js'; +import { validateEventTimes } from './utils.js'; + +/** + * Parse attendees from Google Calendar API response + */ +function parseAttendees(attendees: calendar_v3.Schema$EventAttendee[] | undefined): Attendee[] | undefined { + if (!attendees || attendees.length === 0) { + return undefined; + } + + return attendees.map((attendee) => { + const parsed: Attendee = { + email: attendee.email ?? '', + }; + + // Use intermediate variables to help TypeScript narrow types + const displayName = attendee.displayName; + if (typeof displayName === 'string') { + parsed.displayName = displayName; + } + + const responseStatus = attendee.responseStatus; + if (responseStatus === 'needsAction' || responseStatus === 'declined' || responseStatus === 'tentative' || responseStatus === 'accepted') { + parsed.responseStatus = responseStatus; + } + + if (attendee.organizer === true) { + parsed.organizer = true; + } else if (attendee.organizer === false) { + parsed.organizer = false; + } + + if (attendee.self === true) { + parsed.self = true; + } else if (attendee.self === false) { + parsed.self = false; + } + + if (attendee.optional === true) { + parsed.optional = true; + } else if (attendee.optional === false) { + parsed.optional = false; + } + + return parsed; + }); +} + +/** + * Create a new calendar event + * + * Features: + * - Resolves PAI contact names to email addresses via contacts.ts + * - Automatic UUID generation for Google Meet conferenceData requestId + * - Supports all-day events, recurring events, attachments, reminders + * - Validates event times (end must be after start) + * - Invalidates relevant caches after creation + * + * @param options Event creation parameters + * @param context Calendar API context + * @returns Created event details + * + * @example + * ```typescript + * // Create event with PAI contact names + * const event = await createEvent({ + * summary: 'Team Sync', + * start: { dateTime: '2026-01-09T14:00:00-06:00' }, + * end: { dateTime: '2026-01-09T15:00:00-06:00' }, + * attendees: ['Mary', 'Kelvin'], // Resolves via PAI contacts + * conferenceData: { + * createRequest: { + * requestId: randomUUID(), // Auto-generated if not provided + * conferenceSolutionKey: { type: 'hangoutsMeet' } + * } + * } + * }, context); + * ``` + * + * @example + * ```typescript + * // Create all-day event with raw emails + * const event = await createEvent({ + * summary: 'Team Offsite', + * start: { date: '2026-02-15' }, + * end: { date: '2026-02-16' }, + * attendees: ['user@example.com', 'other@example.com'], + * location: 'San Francisco Office' + * }, context); + * ``` + */ +export async function createEvent( + options: CreateEventOptions, + context: CalendarContext +): Promise { + const { + calendarId = 'primary', + summary, + description, + location, + start, + end, + attendees, + recurrence, + conferenceData, + attachments, + reminders, + visibility, + transparency, + colorId, + timeZone, + } = options; + + // Validate event times + validateEventTimes(start, end); + + // Resolve contact names to emails if attendees provided + let resolvedAttendees: calendar_v3.Schema$EventAttendee[] | undefined; + if (attendees && attendees.length > 0) { + const resolved = await resolveContacts(attendees, context.logger); + resolvedAttendees = resolved.map((contact) => { + const attendee: calendar_v3.Schema$EventAttendee = { + email: contact.email, + }; + if (contact.displayName) { + attendee.displayName = contact.displayName; + } + return attendee; + }); + } + + // Handle Google Meet conference data with auto-generated requestId if needed + let processedConferenceData: calendar_v3.Schema$ConferenceData | undefined; + if (conferenceData?.createRequest) { + processedConferenceData = { + createRequest: { + requestId: conferenceData.createRequest.requestId || randomUUID(), + conferenceSolutionKey: { + type: conferenceData.createRequest.conferenceSolutionKey.type, + }, + }, + }; + } + + // Build event resource + const eventResource: calendar_v3.Schema$Event = { + summary, + }; + + // Only add optional fields if they exist (exactOptionalPropertyTypes compliance) + if (description) { + eventResource.description = description; + } + if (location) { + eventResource.location = location; + } + + // Start and end times (required) + eventResource.start = start; + eventResource.end = end; + + if (resolvedAttendees && resolvedAttendees.length > 0) { + eventResource.attendees = resolvedAttendees; + } + if (recurrence && recurrence.length > 0) { + eventResource.recurrence = recurrence; + } + if (processedConferenceData) { + eventResource.conferenceData = processedConferenceData; + } + if (attachments && attachments.length > 0) { + eventResource.attachments = attachments.map((att) => { + const mapped: calendar_v3.Schema$EventAttachment = {}; + if (att.fileId !== undefined) { + mapped.fileId = att.fileId; + } + if (att.fileUrl !== undefined) { + mapped.fileUrl = att.fileUrl; + } + if (att.title !== undefined) { + mapped.title = att.title; + } + if (att.mimeType !== undefined) { + mapped.mimeType = att.mimeType; + } + if (att.iconLink !== undefined) { + mapped.iconLink = att.iconLink; + } + return mapped; + }); + } + if (reminders) { + eventResource.reminders = {}; + if (reminders.useDefault !== undefined) { + eventResource.reminders.useDefault = reminders.useDefault; + } + if (reminders.overrides !== undefined) { + eventResource.reminders.overrides = reminders.overrides; + } + } + if (visibility) { + eventResource.visibility = visibility; + } + if (transparency) { + eventResource.transparency = transparency; + } + if (colorId) { + eventResource.colorId = colorId; + } + + // Build params + const params: calendar_v3.Params$Resource$Events$Insert = { + calendarId, + requestBody: eventResource, + }; + + // Only set conferenceDataVersion if conference data exists + if (processedConferenceData) { + params.conferenceDataVersion = 1; + } + + // Add timeZone to params if provided (for timezone-aware event creation) + if (timeZone) { + // TimeZone is set in the start/end EventDateTime objects, not at the params level + // So we just ensure it's passed through correctly in the eventResource + } + + const response = await context.calendar.events.insert(params); + + // Build result + const result: EventResult = { + id: response.data.id!, + }; + + // Only add properties if they exist (exactOptionalPropertyTypes compliance) + if (response.data.status) { + result.status = response.data.status; + } + if (response.data.htmlLink) { + result.htmlLink = response.data.htmlLink; + } + if (response.data.created) { + result.created = response.data.created; + } + if (response.data.updated) { + result.updated = response.data.updated; + } + if (response.data.summary) { + result.summary = response.data.summary; + } + if (response.data.description) { + result.description = response.data.description; + } + if (response.data.location) { + result.location = response.data.location; + } + + // Creator + if (response.data.creator) { + result.creator = {}; + if (response.data.creator.email) { + result.creator.email = response.data.creator.email; + } + if (response.data.creator.displayName) { + result.creator.displayName = response.data.creator.displayName; + } + } + + // Organizer + if (response.data.organizer) { + result.organizer = {}; + if (response.data.organizer.email) { + result.organizer.email = response.data.organizer.email; + } + if (response.data.organizer.displayName) { + result.organizer.displayName = response.data.organizer.displayName; + } + } + + // Start/End times + if (response.data.start) { + result.start = {}; + if (response.data.start.dateTime) { + result.start.dateTime = response.data.start.dateTime; + } + if (response.data.start.date) { + result.start.date = response.data.start.date; + } + if (response.data.start.timeZone) { + result.start.timeZone = response.data.start.timeZone; + } + } + + if (response.data.end) { + result.end = {}; + if (response.data.end.dateTime) { + result.end.dateTime = response.data.end.dateTime; + } + if (response.data.end.date) { + result.end.date = response.data.end.date; + } + if (response.data.end.timeZone) { + result.end.timeZone = response.data.end.timeZone; + } + } + + // Recurrence + if (response.data.recurrence && response.data.recurrence.length > 0) { + result.recurrence = response.data.recurrence; + } + + // Attendees + const parsedAttendees = parseAttendees(response.data.attendees); + if (parsedAttendees) { + result.attendees = parsedAttendees; + } + + // Conference data + if (response.data.conferenceData) { + result.conferenceData = response.data.conferenceData; + } + + // Attachments + if (response.data.attachments && response.data.attachments.length > 0) { + result.attachments = response.data.attachments.map((att: calendar_v3.Schema$EventAttachment) => ({ + fileId: att.fileId || '', + fileUrl: att.fileUrl || '', + title: att.title || '', + })); + } + + // Reminders + if (response.data.reminders) { + result.reminders = { + useDefault: response.data.reminders.useDefault || false, + }; + if (response.data.reminders.overrides && response.data.reminders.overrides.length > 0) { + result.reminders.overrides = response.data.reminders.overrides.map((override: calendar_v3.Schema$EventReminder) => ({ + method: override.method || 'popup', + minutes: override.minutes || 0, + })); + } + } + + // Invalidate list caches for this calendar + const listCacheKeys = [ + `calendar:listEvents:${calendarId}:*`, + `calendar:getEvent:${result.id}`, + ]; + for (const pattern of listCacheKeys) { + await context.cacheManager.invalidate(pattern); + } + + context.performanceMonitor.track('calendar:createEvent', Date.now() - context.startTime); + context.logger.info('Created calendar event', { + calendarId, + eventId: result.id, + summary: result.summary, + attendeeCount: parsedAttendees?.length || 0, + }); + + return result; +} + +/** + * Quick add event using natural language + * + * Uses Google Calendar's quick add feature to create events from natural language strings. + * Examples: + * - "Appointment at Somewhere on June 3rd 10am-10:25am" + * - "Dinner with Mary at 7pm tomorrow" + * - "Team meeting next Monday 2pm" + * + * Note: Quick add does NOT support: + * - PAI contact name resolution (use raw emails or full names) + * - Custom conference data (Google Meet links) + * - Attachments + * - Custom reminders + * + * For full control, use createEvent() instead. + * + * @param options Quick add text and calendar ID + * @param context Calendar API context + * @returns Created event details + * + * @example + * ```typescript + * const event = await quickAdd({ + * text: 'Lunch with team at 12pm tomorrow' + * }, context); + * + * console.log(`Created: ${event.summary}`); + * console.log(`Start: ${event.start?.dateTime}`); + * ``` + */ +export async function quickAdd( + options: QuickAddOptions, + context: CalendarContext +): Promise { + const { calendarId = 'primary', text } = options; + + // Build params + const params: calendar_v3.Params$Resource$Events$Quickadd = { + calendarId, + text, + }; + + const response = await context.calendar.events.quickAdd(params); + + // Build result + const result: EventResult = { + id: response.data.id!, + }; + + // Only add properties if they exist (exactOptionalPropertyTypes compliance) + if (response.data.status) { + result.status = response.data.status; + } + if (response.data.htmlLink) { + result.htmlLink = response.data.htmlLink; + } + if (response.data.created) { + result.created = response.data.created; + } + if (response.data.updated) { + result.updated = response.data.updated; + } + if (response.data.summary) { + result.summary = response.data.summary; + } + if (response.data.description) { + result.description = response.data.description; + } + if (response.data.location) { + result.location = response.data.location; + } + + // Creator + if (response.data.creator) { + result.creator = {}; + if (response.data.creator.email) { + result.creator.email = response.data.creator.email; + } + if (response.data.creator.displayName) { + result.creator.displayName = response.data.creator.displayName; + } + } + + // Organizer + if (response.data.organizer) { + result.organizer = {}; + if (response.data.organizer.email) { + result.organizer.email = response.data.organizer.email; + } + if (response.data.organizer.displayName) { + result.organizer.displayName = response.data.organizer.displayName; + } + } + + // Start/End times + if (response.data.start) { + result.start = {}; + if (response.data.start.dateTime) { + result.start.dateTime = response.data.start.dateTime; + } + if (response.data.start.date) { + result.start.date = response.data.start.date; + } + if (response.data.start.timeZone) { + result.start.timeZone = response.data.start.timeZone; + } + } + + if (response.data.end) { + result.end = {}; + if (response.data.end.dateTime) { + result.end.dateTime = response.data.end.dateTime; + } + if (response.data.end.date) { + result.end.date = response.data.end.date; + } + if (response.data.end.timeZone) { + result.end.timeZone = response.data.end.timeZone; + } + } + + // Recurrence + if (response.data.recurrence && response.data.recurrence.length > 0) { + result.recurrence = response.data.recurrence; + } + + // Attendees + const parsedAttendees = parseAttendees(response.data.attendees); + if (parsedAttendees) { + result.attendees = parsedAttendees; + } + + // Conference data + if (response.data.conferenceData) { + result.conferenceData = response.data.conferenceData; + } + + // Attachments + if (response.data.attachments && response.data.attachments.length > 0) { + result.attachments = response.data.attachments.map((att: calendar_v3.Schema$EventAttachment) => ({ + fileId: att.fileId || '', + fileUrl: att.fileUrl || '', + title: att.title || '', + })); + } + + // Reminders + if (response.data.reminders) { + result.reminders = { + useDefault: response.data.reminders.useDefault || false, + }; + if (response.data.reminders.overrides && response.data.reminders.overrides.length > 0) { + result.reminders.overrides = response.data.reminders.overrides.map((override: calendar_v3.Schema$EventReminder) => ({ + method: override.method || 'popup', + minutes: override.minutes || 0, + })); + } + } + + // Invalidate list caches for this calendar + const listCacheKeys = [ + `calendar:listEvents:${calendarId}:*`, + `calendar:getEvent:${result.id}`, + ]; + for (const pattern of listCacheKeys) { + await context.cacheManager.invalidate(pattern); + } + + context.performanceMonitor.track('calendar:quickAdd', Date.now() - context.startTime); + context.logger.info('Quick added calendar event', { + calendarId, + eventId: result.id, + text, + summary: result.summary, + }); + + return result; +} diff --git a/src/modules/calendar/delete.ts b/src/modules/calendar/delete.ts new file mode 100644 index 0000000..7cc9d42 --- /dev/null +++ b/src/modules/calendar/delete.ts @@ -0,0 +1,85 @@ +/** + * Calendar delete operations - deleteEvent + */ + +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { DeleteEventOptions } from './types.js'; + +/** + * Delete a calendar event + * + * Features: + * - Permanently removes event from calendar + * - Supports sendUpdates parameter to notify attendees + * - Invalidates all relevant caches + * + * Warning: This operation is irreversible. The event cannot be recovered + * after deletion. For recurring events, this deletes only the specific + * instance unless you delete the recurring event master. + * + * @param options Delete parameters with event ID + * @param context Calendar API context + * @returns Success confirmation with message + * + * @example + * ```typescript + * // Delete event and notify all attendees + * const result = await deleteEvent({ + * eventId: 'abc123', + * sendUpdates: 'all' + * }, context); + * + * console.log(result.message); // "Event deleted successfully" + * ``` + * + * @example + * ```typescript + * // Delete event silently (no notifications) + * const result = await deleteEvent({ + * eventId: 'abc123', + * sendUpdates: 'none' + * }, context); + * ``` + */ +export async function deleteEvent( + options: DeleteEventOptions, + context: CalendarContext +): Promise<{ success: boolean; message: string }> { + const { + calendarId = 'primary', + eventId, + sendUpdates = 'none', + } = options; + + // Build params + const params: calendar_v3.Params$Resource$Events$Delete = { + calendarId, + eventId, + sendUpdates, + }; + + // Execute delete - Google Calendar API returns 204 No Content on success + await context.calendar.events.delete(params); + + // Invalidate caches for this event and list caches + const cacheKeys = [ + `calendar:getEvent:${eventId}`, + `calendar:listEvents:${calendarId}:*`, + ]; + for (const pattern of cacheKeys) { + await context.cacheManager.invalidate(pattern); + } + + context.performanceMonitor.track('calendar:deleteEvent', Date.now() - context.startTime); + context.logger.info('Deleted calendar event', { + calendarId, + eventId, + sendUpdates, + }); + + return { + success: true, + message: 'Event deleted successfully', + }; +} diff --git a/src/modules/calendar/freebusy.ts b/src/modules/calendar/freebusy.ts new file mode 100644 index 0000000..1095940 --- /dev/null +++ b/src/modules/calendar/freebusy.ts @@ -0,0 +1,115 @@ +/** + * Calendar freebusy operations - checkFreeBusy + * Checks availability for calendars or attendees in a time range + */ + +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { FreeBusyOptions, FreeBusyResult } from './types.js'; + +/** + * Check free/busy status for calendars or attendees + * + * This operation checks availability across multiple calendars in a specified time range. + * Useful for finding available meeting times or checking schedule conflicts. + * + * @param options Free/busy query options (time range and calendar IDs) + * @param context Calendar API context + * @returns Free/busy information for each calendar + * + * @example + * ```typescript + * // Check availability for today + * const result = await checkFreeBusy({ + * timeMin: '2026-01-09T00:00:00Z', + * timeMax: '2026-01-09T23:59:59Z', + * items: [{ id: 'primary' }] + * }, context); + * + * // Check if calendar is free at specific time + * const isFree = result.calendars['primary'].busy.length === 0; + * ``` + */ +export async function checkFreeBusy( + options: FreeBusyOptions, + context: CalendarContext +): Promise { + const { timeMin, timeMax, items, timeZone } = options; + + // Check cache first (short TTL for time-sensitive data) + const cacheKey = `calendar:checkFreeBusy:${JSON.stringify(options)}`; + const cached = await context.cacheManager.get(cacheKey); + if (cached) { + context.performanceMonitor.track( + 'calendar:checkFreeBusy', + Date.now() - context.startTime + ); + return cached as FreeBusyResult; + } + + try { + // Build params object - only include properties that have values + // This is required because of exactOptionalPropertyTypes in tsconfig + const requestBody: calendar_v3.Schema$FreeBusyRequest = { + timeMin, + timeMax, + items, + }; + + if (timeZone) { + requestBody.timeZone = timeZone; + } + + const response = await context.calendar.freebusy.query({ + requestBody, + }); + + const result: FreeBusyResult = { + timeMin: response.data.timeMin!, + timeMax: response.data.timeMax!, + calendars: {}, + }; + + // Transform response calendars to our type format + if (response.data.calendars) { + for (const [calendarId, calendarData] of Object.entries( + response.data.calendars + )) { + result.calendars[calendarId] = { + busy: (calendarData.busy || []).map((period) => ({ + start: period.start!, + end: period.end!, + })), + }; + + // Include errors if present (e.g., calendar not found or access denied) + if (calendarData.errors && calendarData.errors.length > 0) { + result.calendars[calendarId].errors = calendarData.errors.map( + (err) => ({ + domain: err.domain!, + reason: err.reason!, + }) + ); + } + } + } + + // Cache the result (Note: current CacheManager doesn't support per-operation TTL) + // TODO: Implement 60s TTL for freeBusy when CacheManager supports configurable TTL + // Per spec: "cache with short TTL (60s), highly time-sensitive" + await context.cacheManager.set(cacheKey, result); + + context.performanceMonitor.track( + 'calendar:checkFreeBusy', + Date.now() - context.startTime + ); + + return result; + } catch (error) { + context.logger.error('Failed to check free/busy', { + error, + options, + }); + throw error; + } +} diff --git a/src/modules/calendar/index.ts b/src/modules/calendar/index.ts new file mode 100644 index 0000000..bdfccd0 --- /dev/null +++ b/src/modules/calendar/index.ts @@ -0,0 +1,62 @@ +/** + * Calendar module - Google Calendar operations for the gdrive MCP server + * + * @module calendar + * @version 3.3.0 + */ + +// Types - Export all type definitions +export type { + // List types + ListCalendarsOptions, + ListCalendarsResult, + CalendarSummary, + ListEventsOptions, + ListEventsResult, + EventSummary, + // Read types + GetCalendarOptions, + CalendarResult, + GetEventOptions, + EventResult, + // Event types + EventDateTime, + Attendee, + ConferenceData, + EventAttachment, + ReminderSettings, + // Create/Update types + CreateEventOptions, + CreateEventResult, + UpdateEventOptions, + DeleteEventOptions, + DeleteEventResult, + QuickAddOptions, + // FreeBusy types + FreeBusyOptions, + FreeBusyResult, + // Contact types + ResolvedContact, + ContactEntry, +} from './types.js'; + +// List operations +export { listCalendars, listEvents } from './list.js'; + +// Read operations +export { getCalendar, getEvent } from './read.js'; + +// Create operations +export { createEvent, quickAdd } from './create.js'; + +// Update operations +export { updateEvent } from './update.js'; + +// Delete operations +export { deleteEvent } from './delete.js'; + +// FreeBusy operations +export { checkFreeBusy } from './freebusy.js'; + +// Contact resolution (PAI integration) +export { resolveContacts } from './contacts.js'; diff --git a/src/modules/calendar/list.ts b/src/modules/calendar/list.ts new file mode 100644 index 0000000..e2fe7af --- /dev/null +++ b/src/modules/calendar/list.ts @@ -0,0 +1,251 @@ +/** + * Calendar list operations - listCalendars and listEvents + */ + +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { + ListCalendarsOptions, + ListCalendarsResult, + ListEventsOptions, + ListEventsResult, +} from './types.js'; + +/** + * List calendars accessible by the user + * + * @param options List options including pagination + * @param context Calendar API context + * @returns List of calendar summaries with pagination info + * + * @example + * ```typescript + * // List all calendars + * const result = await listCalendars({ + * maxResults: 10 + * }, context); + * + * console.log(`Found ${result.calendars.length} calendars`); + * ``` + */ +export async function listCalendars( + options: ListCalendarsOptions, + context: CalendarContext +): Promise { + const { + maxResults = 10, + pageToken, + showHidden = false, + showDeleted = false, + } = options; + + // Check cache first + const cacheKey = `calendar:listCalendars:${JSON.stringify(options)}`; + const cached = await context.cacheManager.get(cacheKey); + if (cached) { + context.performanceMonitor.track('calendar:listCalendars', Date.now() - context.startTime); + return cached as ListCalendarsResult; + } + + // Build params object - only include properties that have values + // This is required because of exactOptionalPropertyTypes in tsconfig + const params: calendar_v3.Params$Resource$Calendarlist$List = { + maxResults: Math.min(maxResults, 250), // Calendar API limit + showHidden, + showDeleted, + }; + + if (pageToken) { + params.pageToken = pageToken; + } + + const response = await context.calendar.calendarList.list(params); + + const result: ListCalendarsResult = { + calendars: (response.data.items || []).map((cal: calendar_v3.Schema$CalendarListEntry) => { + const calendar: { + id: string; + summary: string; + description?: string; + timeZone?: string; + primary?: boolean; + accessRole?: string; + } = { + id: cal.id!, + summary: cal.summary || '', + }; + + // Only add optional properties if they exist (exactOptionalPropertyTypes compliance) + if (cal.description) { + calendar.description = cal.description; + } + if (cal.timeZone) { + calendar.timeZone = cal.timeZone; + } + if (cal.primary) { + calendar.primary = cal.primary; + } + if (cal.accessRole) { + calendar.accessRole = cal.accessRole; + } + + return calendar; + }), + }; + + // Only add nextPageToken if it exists (exactOptionalPropertyTypes compliance) + if (response.data.nextPageToken) { + result.nextPageToken = response.data.nextPageToken; + } + + // Cache the result (5-minute TTL) + await context.cacheManager.set(cacheKey, result); + context.performanceMonitor.track('calendar:listCalendars', Date.now() - context.startTime); + context.logger.info('Listed calendars', { + count: result.calendars.length, + hasMore: !!result.nextPageToken, + }); + + return result; +} + +/** + * List events in a calendar within a time range + * + * @param options List options including time range and pagination + * @param context Calendar API context + * @returns List of event summaries with pagination info + * + * @example + * ```typescript + * // List upcoming events + * const result = await listEvents({ + * timeMin: '2026-01-09T00:00:00Z', + * maxResults: 20, + * singleEvents: true, + * orderBy: 'startTime' + * }, context); + * + * console.log(`Found ${result.events.length} events`); + * ``` + */ +export async function listEvents( + options: ListEventsOptions, + context: CalendarContext +): Promise { + const { + calendarId = 'primary', + timeMin, + timeMax, + maxResults = 10, + pageToken, + singleEvents = true, + orderBy, + showDeleted = false, + timeZone, + } = options; + + // Check cache first - use calendarId as separate segment for pattern-based invalidation + const cacheKey = `calendar:listEvents:${calendarId}:${JSON.stringify({ timeMin, timeMax, maxResults, pageToken, singleEvents, orderBy, showDeleted, timeZone })}`; + const cached = await context.cacheManager.get(cacheKey); + if (cached) { + context.performanceMonitor.track('calendar:listEvents', Date.now() - context.startTime); + return cached as ListEventsResult; + } + + // Build params object - only include properties that have values + // This is required because of exactOptionalPropertyTypes in tsconfig + const params: calendar_v3.Params$Resource$Events$List = { + calendarId, + maxResults: Math.min(maxResults, 2500), // Calendar API limit + singleEvents, + showDeleted, + }; + + if (timeMin) { + params.timeMin = timeMin; + } + if (timeMax) { + params.timeMax = timeMax; + } + if (pageToken) { + params.pageToken = pageToken; + } + if (orderBy) { + params.orderBy = orderBy; + } + if (timeZone) { + params.timeZone = timeZone; + } + + const response = await context.calendar.events.list(params); + + const result: ListEventsResult = { + events: (response.data.items || []).map((event: calendar_v3.Schema$Event) => { + const eventSummary: { + id: string; + summary?: string; + description?: string; + start?: string; + end?: string; + status?: string; + attendeeCount?: number; + location?: string; + } = { + id: event.id!, + }; + + // Only add optional properties if they exist (exactOptionalPropertyTypes compliance) + if (event.summary) { + eventSummary.summary = event.summary; + } + if (event.description) { + eventSummary.description = event.description; + } + if (event.start?.dateTime || event.start?.date) { + const startTime = event.start.dateTime || event.start.date; + if (startTime) { + eventSummary.start = startTime; + } + } + if (event.end?.dateTime || event.end?.date) { + const endTime = event.end.dateTime || event.end.date; + if (endTime) { + eventSummary.end = endTime; + } + } + if (event.status) { + eventSummary.status = event.status; + } + if (event.attendees && event.attendees.length > 0) { + eventSummary.attendeeCount = event.attendees.length; + } + if (event.location) { + eventSummary.location = event.location; + } + + return eventSummary; + }), + }; + + // Only add optional properties if they exist (exactOptionalPropertyTypes compliance) + if (response.data.nextPageToken) { + result.nextPageToken = response.data.nextPageToken; + } + if (response.data.timeZone) { + result.timeZone = response.data.timeZone; + } + if (response.data.summary) { + result.summary = response.data.summary; + } + + // Cache the result (5-minute TTL) + await context.cacheManager.set(cacheKey, result); + context.performanceMonitor.track('calendar:listEvents', Date.now() - context.startTime); + context.logger.info('Listed events', { + count: result.events.length, + hasMore: !!result.nextPageToken, + }); + + return result; +} diff --git a/src/modules/calendar/read.ts b/src/modules/calendar/read.ts new file mode 100644 index 0000000..24ae8b7 --- /dev/null +++ b/src/modules/calendar/read.ts @@ -0,0 +1,287 @@ +/** + * Calendar read operations - getCalendar and getEvent + */ + +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { + GetCalendarOptions, + CalendarResult, + GetEventOptions, + EventResult, + Attendee, +} from './types.js'; + +/** + * Get details of a specific calendar + * + * @param options Calendar ID + * @param context Calendar API context + * @returns Calendar details + * + * @example + * ```typescript + * const calendar = await getCalendar({ + * calendarId: 'primary' + * }, context); + * + * console.log(`Calendar: ${calendar.summary}`); + * console.log(`Timezone: ${calendar.timeZone}`); + * ``` + */ +export async function getCalendar( + options: GetCalendarOptions, + context: CalendarContext +): Promise { + const { calendarId } = options; + + // Check cache first + const cacheKey = `calendar:getCalendar:${calendarId}`; + const cached = await context.cacheManager.get(cacheKey); + if (cached) { + context.performanceMonitor.track('calendar:getCalendar', Date.now() - context.startTime); + return cached as CalendarResult; + } + + // Build params + const params: calendar_v3.Params$Resource$Calendars$Get = { + calendarId, + }; + + const response = await context.calendar.calendars.get(params); + + const result: CalendarResult = { + id: response.data.id!, + summary: response.data.summary || '', + timeZone: response.data.timeZone || 'UTC', + }; + + // Only add optional properties if they exist (exactOptionalPropertyTypes compliance) + if (response.data.description) { + result.description = response.data.description; + } + if (response.data.location) { + result.location = response.data.location; + } + if (response.data.conferenceProperties) { + result.conferenceProperties = {}; + if (response.data.conferenceProperties.allowedConferenceSolutionTypes) { + result.conferenceProperties.allowedConferenceSolutionTypes = + response.data.conferenceProperties.allowedConferenceSolutionTypes; + } + } + + // Cache the result (5-minute TTL) + await context.cacheManager.set(cacheKey, result); + context.performanceMonitor.track('calendar:getCalendar', Date.now() - context.startTime); + context.logger.info('Retrieved calendar', { + calendarId, + summary: result.summary, + }); + + return result; +} + +/** + * Parse attendees from Google Calendar event + */ +function parseAttendees(attendees: calendar_v3.Schema$EventAttendee[] | undefined): Attendee[] | undefined { + if (!attendees || attendees.length === 0) { + return undefined; + } + + return attendees.map((attendee) => { + const parsed: Attendee = { + email: attendee.email || '', + }; + + if (attendee.displayName) { + parsed.displayName = attendee.displayName; + } + if ( + attendee.responseStatus && + (attendee.responseStatus === 'needsAction' || + attendee.responseStatus === 'declined' || + attendee.responseStatus === 'tentative' || + attendee.responseStatus === 'accepted') + ) { + parsed.responseStatus = attendee.responseStatus; + } + if (attendee.organizer) { + parsed.organizer = attendee.organizer; + } + if (attendee.self) { + parsed.self = attendee.self; + } + if (attendee.optional) { + parsed.optional = attendee.optional; + } + + return parsed; + }); +} + +/** + * Get details of a specific event + * + * @param options Event ID and calendar ID + * @param context Calendar API context + * @returns Full event details + * + * @example + * ```typescript + * const event = await getEvent({ + * eventId: 'abc123' + * }, context); + * + * console.log(`Event: ${event.summary}`); + * console.log(`Start: ${event.start?.dateTime}`); + * console.log(`Attendees: ${event.attendees?.length || 0}`); + * ``` + */ +export async function getEvent( + options: GetEventOptions, + context: CalendarContext +): Promise { + const { calendarId = 'primary', eventId } = options; + + // Check cache first + const cacheKey = `calendar:getEvent:${eventId}`; + const cached = await context.cacheManager.get(cacheKey); + if (cached) { + context.performanceMonitor.track('calendar:getEvent', Date.now() - context.startTime); + return cached as EventResult; + } + + // Build params + const params: calendar_v3.Params$Resource$Events$Get = { + calendarId, + eventId, + }; + + const response = await context.calendar.events.get(params); + + const result: EventResult = { + id: response.data.id!, + }; + + // Only add properties if they exist (exactOptionalPropertyTypes compliance) + if (response.data.status) { + result.status = response.data.status; + } + if (response.data.htmlLink) { + result.htmlLink = response.data.htmlLink; + } + if (response.data.created) { + result.created = response.data.created; + } + if (response.data.updated) { + result.updated = response.data.updated; + } + if (response.data.summary) { + result.summary = response.data.summary; + } + if (response.data.description) { + result.description = response.data.description; + } + if (response.data.location) { + result.location = response.data.location; + } + + // Creator + if (response.data.creator) { + result.creator = {}; + if (response.data.creator.email) { + result.creator.email = response.data.creator.email; + } + if (response.data.creator.displayName) { + result.creator.displayName = response.data.creator.displayName; + } + } + + // Organizer + if (response.data.organizer) { + result.organizer = {}; + if (response.data.organizer.email) { + result.organizer.email = response.data.organizer.email; + } + if (response.data.organizer.displayName) { + result.organizer.displayName = response.data.organizer.displayName; + } + } + + // Start/End times + if (response.data.start) { + result.start = {}; + if (response.data.start.dateTime) { + result.start.dateTime = response.data.start.dateTime; + } + if (response.data.start.date) { + result.start.date = response.data.start.date; + } + if (response.data.start.timeZone) { + result.start.timeZone = response.data.start.timeZone; + } + } + + if (response.data.end) { + result.end = {}; + if (response.data.end.dateTime) { + result.end.dateTime = response.data.end.dateTime; + } + if (response.data.end.date) { + result.end.date = response.data.end.date; + } + if (response.data.end.timeZone) { + result.end.timeZone = response.data.end.timeZone; + } + } + + // Recurrence + if (response.data.recurrence && response.data.recurrence.length > 0) { + result.recurrence = response.data.recurrence; + } + + // Attendees + const attendees = parseAttendees(response.data.attendees); + if (attendees) { + result.attendees = attendees; + } + + // Conference data + if (response.data.conferenceData) { + result.conferenceData = response.data.conferenceData; + } + + // Attachments + if (response.data.attachments && response.data.attachments.length > 0) { + result.attachments = response.data.attachments.map((att) => ({ + fileId: att.fileId || '', + fileUrl: att.fileUrl || '', + title: att.title || '', + })); + } + + // Reminders + if (response.data.reminders) { + result.reminders = { + useDefault: response.data.reminders.useDefault || false, + }; + if (response.data.reminders.overrides && response.data.reminders.overrides.length > 0) { + result.reminders.overrides = response.data.reminders.overrides.map((override) => ({ + method: override.method || 'popup', + minutes: override.minutes || 0, + })); + } + } + + // Cache the result (5-minute TTL) + await context.cacheManager.set(cacheKey, result); + context.performanceMonitor.track('calendar:getEvent', Date.now() - context.startTime); + context.logger.info('Retrieved event', { + eventId, + summary: result.summary, + }); + + return result; +} diff --git a/src/modules/calendar/types.ts b/src/modules/calendar/types.ts new file mode 100644 index 0000000..94626b2 --- /dev/null +++ b/src/modules/calendar/types.ts @@ -0,0 +1,340 @@ +/** + * Calendar module type definitions + * Following Gmail module patterns for consistency + */ + +import type { calendar_v3 } from 'googleapis'; + +/** + * List calendars options + */ +export interface ListCalendarsOptions { + maxResults?: number; + pageToken?: string; + showHidden?: boolean; + showDeleted?: boolean; +} + +/** + * Calendar summary for list operations + */ +export interface CalendarSummary { + id: string; + summary: string; + description?: string; + timeZone?: string; + primary?: boolean; + accessRole?: string; +} + +/** + * List calendars result + */ +export interface ListCalendarsResult { + calendars: CalendarSummary[]; + nextPageToken?: string; +} + +/** + * List events options + */ +export interface ListEventsOptions { + calendarId?: string; + timeMin?: string; + timeMax?: string; + maxResults?: number; + pageToken?: string; + singleEvents?: boolean; + orderBy?: 'startTime' | 'updated'; + showDeleted?: boolean; + timeZone?: string; +} + +/** + * Event summary for list operations + */ +export interface EventSummary { + id: string; + summary?: string; + description?: string; + start?: string; + end?: string; + status?: string; + attendeeCount?: number; + location?: string; +} + +/** + * List events result + */ +export interface ListEventsResult { + events: EventSummary[]; + nextPageToken?: string; + timeZone?: string; + summary?: string; +} + +/** + * Get calendar options + */ +export interface GetCalendarOptions { + calendarId: string; +} + +/** + * Calendar details result + */ +export interface CalendarResult { + id: string; + summary: string; + description?: string; + location?: string; + timeZone: string; + conferenceProperties?: { + allowedConferenceSolutionTypes?: string[]; + }; +} + +/** + * Get event options + */ +export interface GetEventOptions { + calendarId?: string; + eventId: string; +} + +/** + * Attendee information + */ +export interface Attendee { + email: string; + displayName?: string; + responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted'; + organizer?: boolean; + self?: boolean; + optional?: boolean; +} + +/** + * Event date/time + */ +export interface EventDateTime { + dateTime?: string; + date?: string; + timeZone?: string; +} + +/** + * Detailed event result + */ +export interface EventResult { + id: string; + status?: string; + htmlLink?: string; + created?: string; + updated?: string; + summary?: string; + description?: string; + location?: string; + creator?: { + email?: string; + displayName?: string; + }; + organizer?: { + email?: string; + displayName?: string; + }; + start?: EventDateTime; + end?: EventDateTime; + recurrence?: string[]; + attendees?: Attendee[]; + conferenceData?: calendar_v3.Schema$ConferenceData; + attachments?: Array<{ + fileId: string; + fileUrl: string; + title: string; + }>; + reminders?: { + useDefault: boolean; + overrides?: Array<{ + method: string; + minutes: number; + }>; + }; +} + +/** + * Conference data for creating events with Google Meet + */ +export interface ConferenceData { + createRequest?: { + requestId: string; + conferenceSolutionKey: { + type: 'hangoutsMeet' | 'eventHangout' | 'eventNamedHangout'; + }; + }; +} + +/** + * Event attachment (Drive file link) + */ +export interface EventAttachment { + fileId?: string; + fileUrl?: string; + title?: string; + mimeType?: string; + iconLink?: string; +} + +/** + * Reminder settings + */ +export interface ReminderSettings { + useDefault?: boolean; + overrides?: Array<{ + method: 'email' | 'popup'; + minutes: number; + }>; +} + +/** + * Create event options + */ +export interface CreateEventOptions { + calendarId?: string; + summary: string; + description?: string; + location?: string; + start: EventDateTime; + end: EventDateTime; + attendees?: string[]; // Email addresses OR contact names from PAI + recurrence?: string[]; // RRULE strings + conferenceData?: ConferenceData; + attachments?: EventAttachment[]; + reminders?: ReminderSettings; + visibility?: 'default' | 'public' | 'private' | 'confidential'; + transparency?: 'opaque' | 'transparent'; + colorId?: string; + timeZone?: string; +} + +/** + * Create event result + */ +export interface CreateEventResult { + eventId: string; + htmlLink: string; + summary: string; + description?: string; + location?: string; + start: string; + end: string; + attendees?: Attendee[]; + recurrence?: string[]; + created: string; + updated: string; + status: string; + organizer?: { + email: string; + displayName?: string; + }; + creator?: { + email: string; + displayName?: string; + }; + hangoutLink?: string; + conferenceData?: { + entryPoints?: Array<{ + entryPointType: string; + uri: string; + label?: string; + }>; + }; + warnings?: Array<{ + type: string; + message: string; + conflicts?: string[]; + }>; +} + +/** + * Update event options + */ +export interface UpdateEventOptions { + calendarId?: string; + eventId: string; + updates: Partial; + sendUpdates?: 'all' | 'externalOnly' | 'none'; +} + +/** + * Delete event options + */ +export interface DeleteEventOptions { + calendarId?: string; + eventId: string; + sendUpdates?: 'all' | 'externalOnly' | 'none'; +} + +/** + * Delete event result + */ +export interface DeleteEventResult { + eventId: string; + message: string; +} + +/** + * Quick add options (natural language) + */ +export interface QuickAddOptions { + calendarId?: string; + text: string; +} + +/** + * FreeBusy query options + */ +export interface FreeBusyOptions { + timeMin: string; + timeMax: string; + items: Array<{ id: string }>; + timeZone?: string; +} + +/** + * FreeBusy query result + */ +export interface FreeBusyResult { + timeMin: string; + timeMax: string; + calendars: Record; + errors?: Array<{ + domain: string; + reason: string; + }>; + }>; +} + +/** + * Resolved contact with email and metadata + */ +export interface ResolvedContact { + email: string; + displayName?: string; + role?: string; +} + +/** + * Contact entry parsed from PAI CONTACTS.md + */ +export interface ContactEntry { + name: string; // lowercase for matching + displayName: string; // original casing + email: string; + role?: string; +} diff --git a/src/modules/calendar/update.ts b/src/modules/calendar/update.ts new file mode 100644 index 0000000..20a9d41 --- /dev/null +++ b/src/modules/calendar/update.ts @@ -0,0 +1,364 @@ +/** + * Calendar update operations - updateEvent + */ + +import { randomUUID } from 'crypto'; +import type { calendar_v3 } from 'googleapis'; +import type { CalendarContext } from '../types.js'; +import type { + UpdateEventOptions, + EventResult, + Attendee, +} from './types.js'; +import { resolveContacts } from './contacts.js'; +import { validateEventTimes } from './utils.js'; + +/** + * Parse attendees from Google Calendar API response + */ +function parseAttendees(attendees: calendar_v3.Schema$EventAttendee[] | undefined): Attendee[] | undefined { + if (!attendees || attendees.length === 0) { + return undefined; + } + + return attendees.map((attendee) => { + const parsed: Attendee = { + email: attendee.email ?? '', + }; + + // Use intermediate variables to help TypeScript narrow types + const displayName = attendee.displayName; + if (typeof displayName === 'string') { + parsed.displayName = displayName; + } + + const responseStatus = attendee.responseStatus; + if (responseStatus === 'needsAction' || responseStatus === 'declined' || responseStatus === 'tentative' || responseStatus === 'accepted') { + parsed.responseStatus = responseStatus; + } + + if (attendee.organizer === true) { + parsed.organizer = true; + } else if (attendee.organizer === false) { + parsed.organizer = false; + } + + if (attendee.self === true) { + parsed.self = true; + } else if (attendee.self === false) { + parsed.self = false; + } + + if (attendee.optional === true) { + parsed.optional = true; + } else if (attendee.optional === false) { + parsed.optional = false; + } + + return parsed; + }); +} + +/** + * Update an existing calendar event + * + * Features: + * - Partial updates: Only updates fields provided in the updates object + * - Resolves PAI contact names to email addresses for attendees + * - Validates event times if both start and end are updated + * - Supports sendUpdates parameter to notify attendees + * - Invalidates relevant caches after update + * + * Note: This performs a PATCH operation, not a full replacement. + * Only fields in the updates object will be modified. + * + * @param options Update parameters with event ID and partial updates + * @param context Calendar API context + * @returns Updated event details + * + * @example + * ```typescript + * // Update event summary and add attendees + * const event = await updateEvent({ + * eventId: 'abc123', + * updates: { + * summary: 'Updated Meeting Title', + * attendees: ['Mary', 'user@example.com'] + * }, + * sendUpdates: 'all' // Notify all attendees + * }, context); + * ``` + * + * @example + * ```typescript + * // Update event time only + * const event = await updateEvent({ + * eventId: 'abc123', + * updates: { + * start: { dateTime: '2026-01-10T15:00:00-06:00' }, + * end: { dateTime: '2026-01-10T16:00:00-06:00' } + * }, + * sendUpdates: 'externalOnly' // Only notify external attendees + * }, context); + * ``` + */ +export async function updateEvent( + options: UpdateEventOptions, + context: CalendarContext +): Promise { + const { + calendarId = 'primary', + eventId, + updates, + sendUpdates = 'none', + } = options; + + // Validate event times if both start and end are being updated + if (updates.start && updates.end) { + validateEventTimes(updates.start, updates.end); + } + + // Resolve contact names to emails if attendees are being updated + let resolvedAttendees: calendar_v3.Schema$EventAttendee[] | undefined; + if (updates.attendees && updates.attendees.length > 0) { + const resolved = await resolveContacts(updates.attendees, context.logger); + resolvedAttendees = resolved.map((contact) => { + const attendee: calendar_v3.Schema$EventAttendee = { + email: contact.email, + }; + if (contact.displayName) { + attendee.displayName = contact.displayName; + } + return attendee; + }); + } + + // Handle Google Meet conference data with auto-generated requestId if needed + let processedConferenceData: calendar_v3.Schema$ConferenceData | undefined; + if (updates.conferenceData?.createRequest) { + processedConferenceData = { + createRequest: { + requestId: updates.conferenceData.createRequest.requestId || randomUUID(), + conferenceSolutionKey: { + type: updates.conferenceData.createRequest.conferenceSolutionKey.type, + }, + }, + }; + } + + // Build partial update resource (only include fields being updated) + const eventResource: calendar_v3.Schema$Event = {}; + + if (updates.summary !== undefined) { + eventResource.summary = updates.summary; + } + if (updates.description !== undefined) { + eventResource.description = updates.description; + } + if (updates.location !== undefined) { + eventResource.location = updates.location; + } + if (updates.start !== undefined) { + eventResource.start = updates.start; + } + if (updates.end !== undefined) { + eventResource.end = updates.end; + } + if (resolvedAttendees !== undefined) { + eventResource.attendees = resolvedAttendees; + } + if (updates.recurrence !== undefined) { + eventResource.recurrence = updates.recurrence; + } + if (processedConferenceData !== undefined) { + eventResource.conferenceData = processedConferenceData; + } + if (updates.attachments !== undefined) { + eventResource.attachments = updates.attachments.map((att) => { + const mapped: calendar_v3.Schema$EventAttachment = {}; + if (att.fileId !== undefined) { + mapped.fileId = att.fileId; + } + if (att.fileUrl !== undefined) { + mapped.fileUrl = att.fileUrl; + } + if (att.title !== undefined) { + mapped.title = att.title; + } + if (att.mimeType !== undefined) { + mapped.mimeType = att.mimeType; + } + if (att.iconLink !== undefined) { + mapped.iconLink = att.iconLink; + } + return mapped; + }); + } + if (updates.reminders !== undefined) { + eventResource.reminders = {}; + if (updates.reminders.useDefault !== undefined) { + eventResource.reminders.useDefault = updates.reminders.useDefault; + } + if (updates.reminders.overrides !== undefined) { + eventResource.reminders.overrides = updates.reminders.overrides; + } + } + if (updates.visibility !== undefined) { + eventResource.visibility = updates.visibility; + } + if (updates.transparency !== undefined) { + eventResource.transparency = updates.transparency; + } + if (updates.colorId !== undefined) { + eventResource.colorId = updates.colorId; + } + + // Build params + const params: calendar_v3.Params$Resource$Events$Patch = { + calendarId, + eventId, + requestBody: eventResource, + sendUpdates, + }; + + // Only set conferenceDataVersion if conference data exists + if (processedConferenceData) { + params.conferenceDataVersion = 1; + } + + const response = await context.calendar.events.patch(params); + + // Build result + const result: EventResult = { + id: response.data.id!, + }; + + // Only add properties if they exist (exactOptionalPropertyTypes compliance) + if (response.data.status) { + result.status = response.data.status; + } + if (response.data.htmlLink) { + result.htmlLink = response.data.htmlLink; + } + if (response.data.created) { + result.created = response.data.created; + } + if (response.data.updated) { + result.updated = response.data.updated; + } + if (response.data.summary) { + result.summary = response.data.summary; + } + if (response.data.description) { + result.description = response.data.description; + } + if (response.data.location) { + result.location = response.data.location; + } + + // Creator + if (response.data.creator) { + result.creator = {}; + if (response.data.creator.email) { + result.creator.email = response.data.creator.email; + } + if (response.data.creator.displayName) { + result.creator.displayName = response.data.creator.displayName; + } + } + + // Organizer + if (response.data.organizer) { + result.organizer = {}; + if (response.data.organizer.email) { + result.organizer.email = response.data.organizer.email; + } + if (response.data.organizer.displayName) { + result.organizer.displayName = response.data.organizer.displayName; + } + } + + // Start/End times + if (response.data.start) { + result.start = {}; + if (response.data.start.dateTime) { + result.start.dateTime = response.data.start.dateTime; + } + if (response.data.start.date) { + result.start.date = response.data.start.date; + } + if (response.data.start.timeZone) { + result.start.timeZone = response.data.start.timeZone; + } + } + + if (response.data.end) { + result.end = {}; + if (response.data.end.dateTime) { + result.end.dateTime = response.data.end.dateTime; + } + if (response.data.end.date) { + result.end.date = response.data.end.date; + } + if (response.data.end.timeZone) { + result.end.timeZone = response.data.end.timeZone; + } + } + + // Recurrence + if (response.data.recurrence && response.data.recurrence.length > 0) { + result.recurrence = response.data.recurrence; + } + + // Attendees + const parsedAttendees = parseAttendees(response.data.attendees); + if (parsedAttendees) { + result.attendees = parsedAttendees; + } + + // Conference data + if (response.data.conferenceData) { + result.conferenceData = response.data.conferenceData; + } + + // Attachments + if (response.data.attachments && response.data.attachments.length > 0) { + result.attachments = response.data.attachments.map((att: calendar_v3.Schema$EventAttachment) => ({ + fileId: att.fileId || '', + fileUrl: att.fileUrl || '', + title: att.title || '', + })); + } + + // Reminders + if (response.data.reminders) { + result.reminders = { + useDefault: response.data.reminders.useDefault || false, + }; + if (response.data.reminders.overrides && response.data.reminders.overrides.length > 0) { + result.reminders.overrides = response.data.reminders.overrides.map((override: calendar_v3.Schema$EventReminder) => ({ + method: override.method || 'popup', + minutes: override.minutes || 0, + })); + } + } + + // Invalidate caches for this event and list caches + const cacheKeys = [ + `calendar:getEvent:${eventId}`, + `calendar:listEvents:${calendarId}:*`, + ]; + for (const pattern of cacheKeys) { + await context.cacheManager.invalidate(pattern); + } + + context.performanceMonitor.track('calendar:updateEvent', Date.now() - context.startTime); + context.logger.info('Updated calendar event', { + calendarId, + eventId, + fieldsUpdated: Object.keys(updates), + sendUpdates, + }); + + return result; +} diff --git a/src/modules/calendar/utils.ts b/src/modules/calendar/utils.ts new file mode 100644 index 0000000..c99e5bf --- /dev/null +++ b/src/modules/calendar/utils.ts @@ -0,0 +1,45 @@ +/** + * Shared calendar utilities + */ + +/** + * Validate event time parameters + * - Ensures end time is after start time + * - Validates all-day events use 'date' field, not 'dateTime' + * + * @param start Event start time + * @param end Event end time + * @throws Error if validation fails + */ +export function validateEventTimes( + start: { dateTime?: string; date?: string }, + end: { dateTime?: string; date?: string } +): void { + // Check all-day event consistency + if (start.date && start.dateTime) { + throw new Error("All-day events should use 'date' field, not 'dateTime'"); + } + if (end.date && end.dateTime) { + throw new Error("All-day events should use 'date' field, not 'dateTime'"); + } + + // Check end is after start (for dateTime events) + if (start.dateTime && end.dateTime) { + const startTime = new Date(start.dateTime).getTime(); + const endTime = new Date(end.dateTime).getTime(); + + if (endTime <= startTime) { + throw new Error('Event end time must be after start time'); + } + } + + // Check end is after start (for all-day events) + if (start.date && end.date) { + const startTime = new Date(start.date).getTime(); + const endTime = new Date(end.date).getTime(); + + if (endTime <= startTime) { + throw new Error('Event end time must be after start time'); + } + } +} diff --git a/src/modules/types.ts b/src/modules/types.ts index 06ad0cb..7ffe20d 100644 --- a/src/modules/types.ts +++ b/src/modules/types.ts @@ -2,7 +2,7 @@ * Shared types for all Google Drive MCP modules */ -import type { drive_v3, sheets_v4, forms_v1, docs_v1, gmail_v1 } from 'googleapis'; +import type { drive_v3, sheets_v4, forms_v1, docs_v1, gmail_v1, calendar_v3 } from 'googleapis'; import type { Logger } from 'winston'; /** @@ -66,6 +66,13 @@ export interface GmailContext extends BaseContext { gmail: gmail_v1.Gmail; } +/** + * Context for Google Calendar operations + */ +export interface CalendarContext extends BaseContext { + calendar: calendar_v3.Calendar; +} + /** * Standard result format for module operations */ diff --git a/src/tools/listTools.ts b/src/tools/listTools.ts index 78f2b85..008d095 100644 --- a/src/tools/listTools.ts +++ b/src/tools/listTools.ts @@ -244,6 +244,62 @@ export async function generateToolStructure(): Promise { example: 'gmail.modifyLabels({ messageId: "18c123abc", removeLabelIds: ["UNREAD", "INBOX"] })', }, ], + calendar: [ + { + name: 'listCalendars', + signature: 'listCalendars({ maxResults?: number, pageToken?: string })', + description: 'List all calendars accessible by the user', + example: 'calendar.listCalendars({ maxResults: 10 })', + }, + { + name: 'getCalendar', + signature: 'getCalendar({ calendarId: string })', + description: 'Get details of a specific calendar', + example: 'calendar.getCalendar({ calendarId: "primary" })', + }, + { + name: 'listEvents', + signature: 'listEvents({ calendarId?: string, timeMin?: string, timeMax?: string, maxResults?: number })', + description: 'List events in a calendar within a time range', + example: 'calendar.listEvents({ timeMin: "2026-01-08T00:00:00Z", maxResults: 20 })', + }, + { + name: 'getEvent', + signature: 'getEvent({ calendarId?: string, eventId: string })', + description: 'Get details of a specific event', + example: 'calendar.getEvent({ eventId: "abc123" })', + }, + { + name: 'createEvent', + signature: 'createEvent({ summary: string, start: EventDateTime, end: EventDateTime, attendees?: string[], ... })', + description: 'Create a new calendar event with optional attendees and recurrence', + example: 'calendar.createEvent({ summary: "Team Standup", start: { dateTime: "2026-01-09T09:00:00-06:00" }, end: { dateTime: "2026-01-09T09:30:00-06:00" }, attendees: ["Mary", "Kelvin"] })', + }, + { + name: 'updateEvent', + signature: 'updateEvent({ eventId: string, updates: Partial })', + description: 'Update an existing event', + example: 'calendar.updateEvent({ eventId: "abc123", updates: { summary: "Updated Meeting Title" } })', + }, + { + name: 'deleteEvent', + signature: 'deleteEvent({ eventId: string, sendUpdates?: "all" | "externalOnly" | "none" })', + description: 'Delete an event and optionally notify attendees', + example: 'calendar.deleteEvent({ eventId: "abc123", sendUpdates: "all" })', + }, + { + name: 'quickAdd', + signature: 'quickAdd({ text: string, calendarId?: string })', + description: 'Create an event from natural language text', + example: 'calendar.quickAdd({ text: "Lunch with Mary tomorrow at noon" })', + }, + { + name: 'checkFreeBusy', + signature: 'checkFreeBusy({ timeMin: string, timeMax: string, items: { id: string }[] })', + description: 'Check availability for calendars or attendees in a time range', + example: 'calendar.checkFreeBusy({ timeMin: "2026-01-09T00:00:00Z", timeMax: "2026-01-09T23:59:59Z", items: [{ id: "primary" }] })', + }, + ], }; }