From 570520f0ccde6a5bed5bfa08ab61dc39a9166d47 Mon Sep 17 00:00:00 2001 From: Russell Zager Date: Thu, 2 Apr 2026 09:29:13 -0700 Subject: [PATCH 1/2] fix(calendar): use events.patch instead of events.update to preserve all event fields The updateEvent method used events.update (PUT), which replaces the entire event resource. Any field not explicitly included in the request body gets wiped -- including summary, description, reminders, colorId, visibility, recurrence, attachments, and conferenceData. Critically, it also rejects non-default eventTypes (Focus Time, Out of Office, Working Location) with Event type cannot be changed because omitting eventType is interpreted as changing it to default. Switch to events.patch (PATCH), which only modifies the fields present in the request body and preserves everything else. This is the correct HTTP semantics for partial updates and matches the methods documented intent (patch semantics comment was already in the code). Fixes updating Focus Time, Out of Office, and Working Location events. Also fixes data loss when updating any event with only a subset of fields (e.g., changing only the time without re-specifying the title). Ref: https://developers.google.com/calendar/api/v3/reference/events/patch --- workspace-server/src/services/CalendarService.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index e67f3bb..fda8abc 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -724,7 +724,7 @@ export class CalendarService { try { const calendar = await this.getCalendar(); - // Build request body with only the fields to update (patch semantics) + // Build request body with only the fields to update (true patch semantics) const requestBody: calendar_v3.Schema$Event = {}; if (summary !== undefined) requestBody.summary = summary; if (description !== undefined) requestBody.description = description; @@ -733,19 +733,24 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const updateParams: calendar_v3.Params$Resource$Events$Update = { + const patchParams: calendar_v3.Params$Resource$Events$Patch = { calendarId: finalCalendarId, eventId, requestBody, }; this.applyMeetAndAttachments( requestBody, - updateParams, + patchParams as calendar_v3.Params$Resource$Events$Update, addGoogleMeet, attachments, ); - const res = await calendar.events.update(updateParams); + // Use events.patch (not events.update) so only specified fields are modified. + // events.update (PUT) replaces the entire event, which wipes unspecified fields + // like summary, description, reminders, colorId, visibility, and — critically — + // rejects non-default eventTypes (Focus Time, Out of Office, Working Location) + // with "Event type cannot be changed." + const res = await calendar.events.patch(patchParams); logToFile(`Successfully updated event: ${res.data.id}`); return { From d26e5d5efc362ffeab868489098990069fab6f10 Mon Sep 17 00:00:00 2001 From: Russell Zager Date: Thu, 2 Apr 2026 09:43:05 -0700 Subject: [PATCH 2/2] fix(calendar): use events.patch instead of events.update to preserve all event fields The updateEvent method used events.update (PUT), which replaces the entire event resource. Any field not explicitly included in the request body gets wiped -- including summary, description, reminders, colorId, visibility, recurrence, attachments, and conferenceData. Critically, it also rejects non-default eventTypes (Focus Time, Out of Office, Working Location) with Event type cannot be changed because omitting eventType is interpreted as changing it to default. Switch to events.patch (PATCH), which only modifies the fields present in the request body and preserves everything else. This is the correct HTTP semantics for partial updates and matches the methods documented intent (patch semantics comment was already in the code). Changes: - Replace events.update with events.patch in CalendarService.ts - Remove unnecessary type cast on applyMeetAndAttachments call - Update all updateEvent test mocks from events.update to events.patch - Add test verifying Focus Time events are preserved on partial update Ref: https://developers.google.com/calendar/api/v3/reference/events/patch --- .../services/CalendarService.test.ts | 63 +++++++++++++++---- .../src/services/CalendarService.ts | 2 +- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index 94fde5e..9a5b543 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -857,7 +857,7 @@ describe('CalendarService', () => { attendees: [{ email: 'new@example.com' }], }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -867,7 +867,7 @@ describe('CalendarService', () => { attendees: ['new@example.com'], }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -889,14 +889,14 @@ describe('CalendarService', () => { description: 'New updated description', }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', description: 'New updated description', }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -910,7 +910,7 @@ describe('CalendarService', () => { it('should handle update errors', async () => { const apiError = new Error('Update failed'); - mockCalendarAPI.events.update.mockRejectedValue(apiError); + mockCalendarAPI.events.patch.mockRejectedValue(apiError); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -927,14 +927,14 @@ describe('CalendarService', () => { summary: 'Updated Meeting Only', }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); await calendarService.updateEvent({ eventId: 'event123', summary: 'Updated Meeting Only', }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -942,6 +942,43 @@ describe('CalendarService', () => { }, }); }); + + it('should preserve Focus Time eventType on partial update', async () => { + const focusTimeEvent = { + id: 'focus123', + summary: 'Deep Work', + eventType: 'focusTime', + focusTimeProperties: { autoDeclineMode: 'declineNone' }, + start: { dateTime: '2024-01-15T10:00:00Z' }, + end: { dateTime: '2024-01-15T12:00:00Z' }, + }; + + mockCalendarAPI.events.patch.mockResolvedValue({ data: focusTimeEvent }); + + const result = await calendarService.updateEvent({ + eventId: 'focus123', + start: { dateTime: '2024-01-16T10:00:00Z' }, + end: { dateTime: '2024-01-16T12:00:00Z' }, + }); + + // Verify patch was called (not update) — patch preserves unspecified fields + expect(mockCalendarAPI.events.patch).toHaveBeenCalled(); + expect(mockCalendarAPI.events.update).not.toHaveBeenCalled(); + + // Verify only the requested fields were sent (eventType is NOT in the body — + // it is preserved server-side by PATCH semantics) + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { + start: { dateTime: '2024-01-16T10:00:00Z' }, + end: { dateTime: '2024-01-16T12:00:00Z' }, + }, + }), + ); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.eventType).toBe('focusTime'); + }); }); describe('respondToEvent', () => { @@ -1495,14 +1532,14 @@ describe('CalendarService', () => { }, }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', addGoogleMeet: true, }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.conferenceDataVersion).toBe(1); expect(callArgs.requestBody.conferenceData).toBeDefined(); expect( @@ -1515,7 +1552,7 @@ describe('CalendarService', () => { it('should not include conferenceData when addGoogleMeet is false', async () => { const updatedEvent = { id: 'event123', summary: 'No Meet' }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); await calendarService.updateEvent({ eventId: 'event123', @@ -1523,7 +1560,7 @@ describe('CalendarService', () => { addGoogleMeet: false, }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.conferenceDataVersion).toBeUndefined(); expect(callArgs.requestBody.conferenceData).toBeUndefined(); }); @@ -1541,7 +1578,7 @@ describe('CalendarService', () => { ], }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -1553,7 +1590,7 @@ describe('CalendarService', () => { ], }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.supportsAttachments).toBe(true); expect(callArgs.requestBody.attachments).toEqual([ expect.objectContaining({ diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index fda8abc..a57db2a 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -740,7 +740,7 @@ export class CalendarService { }; this.applyMeetAndAttachments( requestBody, - patchParams as calendar_v3.Params$Resource$Events$Update, + patchParams, addGoogleMeet, attachments, );