fix(calendar): suppress provider invitations and deduplicate ICS builder #55
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@when/worker': patch
|
||||
---
|
||||
|
||||
suppress host calendar provider notifications avoiding duplicate confirmations
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in new issue
Block a user