fix(calendar): suppress provider invitations and deduplicate ICS builder #55

Merged
eleith merged 1 commits from fix/suppress-duplicate-calendar-invites into main 2026-08-31 12:33:12 +00:00
6 changed files with 53 additions and 66 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@when/worker': patch
---
suppress host calendar provider notifications avoiding duplicate confirmations
+2 -60
View File
@@ -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 {
+30 -1
View File
@@ -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<typeof fetch>[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).
+3 -3
View File
@@ -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<void> {
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',
+1 -1
View File
@@ -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', () => {
+12 -1
View File
@@ -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 } {