From 09c9947aeffa5774e5714c5760283e3e7f73be2d Mon Sep 17 00:00:00 2001 From: eleith Date: Sun, 30 Aug 2026 20:17:04 -0700 Subject: [PATCH] fix(calendar): suppress provider invitations and deduplicate ICS builder - Append sendUpdates=none to Google Calendar API event create, update, and delete calls - Inject SCHEDULE-AGENT=CLIENT into ATTENDEE lines per RFC 6638 for CalDAV servers - Refactor apps/worker to consume canonical buildIcs from @when/calendar --- .changeset/ready-keys-knock.md | 5 ++ apps/worker/src/email/ics.ts | 62 +------------------ packages/calendar/src/adapters/google.test.ts | 31 +++++++++- packages/calendar/src/adapters/google.ts | 6 +- packages/calendar/src/ics.test.ts | 2 +- packages/calendar/src/ics.ts | 13 +++- 6 files changed, 53 insertions(+), 66 deletions(-) create mode 100644 .changeset/ready-keys-knock.md diff --git a/.changeset/ready-keys-knock.md b/.changeset/ready-keys-knock.md new file mode 100644 index 00000000..60c90449 --- /dev/null +++ b/.changeset/ready-keys-knock.md @@ -0,0 +1,5 @@ +--- +'@when/worker': patch +--- + +suppress host calendar provider notifications avoiding duplicate confirmations diff --git a/apps/worker/src/email/ics.ts b/apps/worker/src/email/ics.ts index e14a99e1..3134677f 100644 --- a/apps/worker/src/email/ics.ts +++ b/apps/worker/src/email/ics.ts @@ -1,68 +1,10 @@ -import { generateIcsCalendar, type IcsCalendar, type IcsEvent } from 'ts-ics'; -import { guestContact, describeAppointment } from '@when/calendar'; +import { buildIcs, type IcsInput, type IcsMethod } from '@when/calendar'; import { senderEmail } from '@when/config'; -import { originId, type Appointment, type AppointmentStatus } from '@when/db'; -import { systemClock, type Clock } from './clock.js'; import { eventTypeName } from './format.js'; import type { Attachment } from './recipients.js'; import type { AppointmentEmailInput } from './types.js'; -export type IcsMethod = 'REQUEST' | 'CANCEL'; - -export interface IcsInput { - appointment: Appointment; - eventTypeName: string; - hostName: string; - hostEmail: string; - /** Public URL the booker can use to cancel or reschedule. */ - cancelUrl: string; - /** - * Set for scheduling messages (iMIP email attachments): emits a calendar-level - * `METHOD` line and forces the corresponding event STATUS. - */ - method?: IcsMethod; - clock?: Clock; -} - -export function buildIcs(input: IcsInput): string { - const { appointment, eventTypeName: name, hostName, hostEmail, cancelUrl, method } = input; - const clock = input.clock ?? systemClock; - const guest = guestContact(appointment); - - const event: IcsEvent = { - uid: originId(appointment), - sequence: appointment.ics_sequence, - summary: name, - start: { date: new Date(appointment.start_time), type: 'DATE-TIME' }, - end: { date: new Date(appointment.end_time), type: 'DATE-TIME' }, - stamp: { date: clock.now(), type: 'DATE-TIME' }, - description: describeAppointment(appointment, cancelUrl), - location: appointment.location ?? undefined, - organizer: { name: hostName, email: hostEmail }, - // The guest booked, so they've effectively accepted; mark it so calendar - // clients don't prompt them to RSVP to the noreply organizer. - attendees: guest ? [{ ...guest, partstat: 'ACCEPTED', rsvp: false }] : undefined, - status: eventStatus(method, appointment.status) - }; - - const calendar: IcsCalendar = { - prodId: '-//When//EN', - version: '2.0', - events: [event] - }; - if (method) calendar.method = method; - - return generateIcsCalendar(calendar); -} - -function eventStatus( - method: IcsMethod | undefined, - appointmentStatus: AppointmentStatus -): 'CANCELLED' | 'TENTATIVE' | 'CONFIRMED' { - if (method === 'CANCEL') return 'CANCELLED'; - if (appointmentStatus === 'pending') return 'TENTATIVE'; - return 'CONFIRMED'; -} +export { buildIcs, type IcsInput, type IcsMethod }; function icsAttachment(input: IcsInput): Attachment { return { diff --git a/packages/calendar/src/adapters/google.test.ts b/packages/calendar/src/adapters/google.test.ts index 4488209a..40c5c4ce 100644 --- a/packages/calendar/src/adapters/google.test.ts +++ b/packages/calendar/src/adapters/google.test.ts @@ -6,6 +6,7 @@ import { revokeGoogleToken, listGoogleCalendars, putGoogleEvent, + deleteGoogleEvent, type GoogleConfig } from './google.js'; import type { Appointment } from '@when/db'; @@ -109,8 +110,36 @@ test('includes conferenceData and appends version query parameter when video_cha }); test('omits conferenceData when video_chat link is absent', async () => { - const { payload } = await push(baseAppointment); + const { payload, url } = await push(baseAppointment); expect(payload?.conferenceData).toBeUndefined(); + expect(url).toContain('sendUpdates=none'); +}); + +test('putGoogleEvent includes sendUpdates=none on update (PUT)', async () => { + const { url } = await push({ + ...baseAppointment, + external_event_id: 'existing-evt-1' + }); + expect(url).toContain('/events/existing-evt-1?conferenceDataVersion=1&sendUpdates=none'); +}); + +test('deleteGoogleEvent includes sendUpdates=none', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (url: Parameters[0]) => { + if (String(url).includes('oauth2.googleapis.com/token')) { + return new Response(JSON.stringify({ access_token: 't', expires_in: 3600 }), { + status: 200 + }); + } + return new Response(null, { status: 204 }); + }); + + await deleteGoogleEvent(cfg, 'evt-to-delete'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://www.googleapis.com/calendar/v3/calendars/cal/events/evt-to-delete?sendUpdates=none', + expect.objectContaining({ method: 'DELETE' }) + ); }); // getGoogleAccessToken is re-exported for the CLI (service test/token/calendars). diff --git a/packages/calendar/src/adapters/google.ts b/packages/calendar/src/adapters/google.ts index 26f900c1..31982c28 100644 --- a/packages/calendar/src/adapters/google.ts +++ b/packages/calendar/src/adapters/google.ts @@ -166,8 +166,8 @@ export async function putGoogleEvent( const eventId = isUpdate ? appointment.external_event_id : ''; const url = isUpdate - ? `https://www.googleapis.com/calendar/v3/calendars/${calId}/events/${eventId}?conferenceDataVersion=1` - : `https://www.googleapis.com/calendar/v3/calendars/${calId}/events?conferenceDataVersion=1`; + ? `https://www.googleapis.com/calendar/v3/calendars/${calId}/events/${eventId}?conferenceDataVersion=1&sendUpdates=none` + : `https://www.googleapis.com/calendar/v3/calendars/${calId}/events?conferenceDataVersion=1&sendUpdates=none`; const method = isUpdate ? 'PUT' : 'POST'; @@ -249,7 +249,7 @@ export async function putGoogleEvent( export async function deleteGoogleEvent(cfg: GoogleConfig, externalEventId: string): Promise { const token = await getGoogleAccessToken(cfg); const calId = encodeURIComponent(cfg.google_calendar_id); - const url = `https://www.googleapis.com/calendar/v3/calendars/${calId}/events/${externalEventId}`; + const url = `https://www.googleapis.com/calendar/v3/calendars/${calId}/events/${externalEventId}?sendUpdates=none`; const res = await fetch(url, { method: 'DELETE', diff --git a/packages/calendar/src/ics.test.ts b/packages/calendar/src/ics.test.ts index 2917ed3b..56eaf600 100644 --- a/packages/calendar/src/ics.test.ts +++ b/packages/calendar/src/ics.test.ts @@ -107,7 +107,7 @@ test('STATUS is CONFIRMED for confirmed appointments', () => { test('ORGANIZER and ATTENDEE lines are present', () => { const ics = buildIcs({ appointment: baseAppointment, ...baseInput }); expect(ics).toMatch(/ORGANIZER[;:][^\r\n]*jane@example\.com/); - expect(ics).toMatch(/ATTENDEE[;:][^\r\n]*booker@example\.com/); + expect(ics).toMatch(/ATTENDEE[;:][^\r\n]*SCHEDULE-AGENT=CLIENT[;:][^\r\n]*booker@example\.com/); }); test('ATTENDEE is omitted when the appointment has no email', () => { diff --git a/packages/calendar/src/ics.ts b/packages/calendar/src/ics.ts index f5abce05..527db855 100644 --- a/packages/calendar/src/ics.ts +++ b/packages/calendar/src/ics.ts @@ -58,7 +58,7 @@ export function buildIcs(input: IcsInput): string { }; if (method) calendar.method = method; - return generateIcsCalendar(calendar, { + const generated = generateIcsCalendar(calendar, { nonStandard: { conference: { name: 'CONFERENCE' as 'X-CONFERENCE', @@ -66,6 +66,17 @@ export function buildIcs(input: IcsInput): string { } } }); + + return withClientScheduleAgent(generated); +} + +/** + * Injects RFC 6638 ยง 3.2.4 `SCHEDULE-AGENT=CLIENT` into ATTENDEE properties. + * This instructs CalDAV servers (e.g. Nextcloud, SabreDAV, Fastmail, Apple Calendar) + * that the client application handles scheduling and to suppress server-sent emails. + */ +function withClientScheduleAgent(ics: string): string { + return ics.replaceAll('ATTENDEE;', 'ATTENDEE;SCHEDULE-AGENT=CLIENT;'); } function icsPerson(name: string, email: string): { name: string; email: string } { -- 2.54.0