From fabecb2128afcdc3771cc9eb0da5a3ce242a198b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:18:23 +0200 Subject: [PATCH] fix: ask attendees again when their organizer moves an event Saving an event in the editor keeps the attendees' answers. When the user organizes the event and the save changes when it happens, RFC 5546 has the organizer ask the attendees again, and Stalwart's update to them carries each attendee's stored answer: the old answers would have read as answers to the new time. - the editor compares the event's start and end instants, and whether it is all-day, with its form, to the minute the form shows; writing the event in the user's time zone instead of its own is no change - when the user organizes the event, under any of their addresses, and it moves, every other attendee goes back to needs-action; the organizer's own entry stays as it is - every other save keeps the answers, and so does any save of an event someone else organizes - the answers are reset in what the save covers: one occurrence, this and the following ones, or the whole series --- .../__tests__/event-modal-organizer.test.tsx | 83 ++++++++++++++++++- components/calendar/event-modal.tsx | 33 +++++++- lib/__tests__/calendar-participants.test.ts | 54 ++++++++++++ lib/__tests__/event-modal-reschedule.test.ts | 47 +++++++++++ lib/calendar-participants.ts | 19 ++++- 5 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/event-modal-reschedule.test.ts diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx index 74fbbded3..fc054e74f 100644 --- a/components/calendar/__tests__/event-modal-organizer.test.tsx +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -1,7 +1,9 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventModal } from '../event-modal'; import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import { useSettingsStore } from '@/stores/settings-store'; // The editor rewrote the organizer on every save of an event with attendees, // to the user's first address, and put every attendee back to needs-action. @@ -82,3 +84,82 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and a expect(participants.guest.participationStatus).toBe('tentative'); }); }); + +// RFC 5546: when the organizer moves an event, the attendees are asked +// again; every other save keeps their answers. The editor shows and writes +// the event in the user's zone (here Tokyo, UTC+9), which is no move by +// itself. +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a move on Stalwart %s', (version) => { + beforeEach(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' })); + afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); + + // 08:00-09:00 in New York (UTC-4 in October): 21:00-22:00 in Tokyo. + function inNewYork(organizer: string, participants: Record): CalendarEvent { + return { + ...meeting(organizer, participants), + start: '2026-10-12T08:00:00', timeZone: 'America/New_York', + utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z', + } as CalendarEvent; + } + const own = () => inNewYork('me@example.com', { + org: participant(version, 'me@example.com', true, 'accepted'), + bob: participant(version, 'bob@example.org', false, 'accepted'), + carol: participant(version, 'carol@example.org', false, 'declined'), + }); + + async function save(event: CalendarEvent, edit: (container: HTMLElement) => void): Promise> { + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + fireEvent.click(screen.getByText('events.edit')); + edit(container); + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0][0] as Partial; + } + const moveStartTo = (time: string) => (container: HTMLElement) => { + const [startTime] = container.querySelectorAll('input[type="time"]'); + fireEvent.change(startTime, { target: { value: time } }); + }; + const statuses = (data: Partial) => Object.fromEntries( + Object.entries(data.participants as Record).map(([id, p]) => [id, p.participationStatus]), + ); + + it('asks the attendees again when the user moves their event', async () => { + const data = await save(own(), moveStartTo('22:00')); + + expect(data.start).toBe('2026-10-12T22:00:00'); + expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' }); + }); + + it('keeps the answers when only the time zone is rewritten', async () => { + const data = await save(own(), () => {}); + + // Written in the user's zone, at the same instant. + expect(data).toMatchObject({ start: '2026-10-12T21:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' }); + }); + + it('keeps the answers when the title changes', async () => { + const data = await save(own(), () => { + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning, room 2' } }); + }); + + expect(data.title).toBe('Planning, room 2'); + expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' }); + }); + + it('never asks again when the user did not organize the event', async () => { + const invitation = inNewYork('boss@example.org', { + boss: participant(version, 'boss@example.org', true, 'accepted'), + mine: participant(version, 'me@example.com', false, 'accepted'), + bob: participant(version, 'bob@example.org', false, 'declined'), + }); + + const data = await save(invitation, moveStartTo('22:00')); + + expect(data.start).toBe('2026-10-12T22:00:00'); + expect(statuses(data)).toEqual({ boss: 'accepted', mine: 'accepted', bob: 'declined' }); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index df42ff331..fe5485b60 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -89,6 +89,27 @@ export function savedStatusFields( return { status: "confirmed", freeBusyStatus: "busy", privacy: "public" }; } +/** + * Whether the editor's dates change when `event` happens: it turns all-day + * or timed, or its start or end moves - for an all-day event its first or + * last day. The form's dates and the event's are display dates in the + * user's zone, compared to the minute the form shows: saving the event in + * the user's zone instead of its own is no change. Pure, for testing. + */ +export function changesWhen( + event: CalendarEvent, + next: { allDay: boolean; start: Date; end: Date }, +): boolean { + if (!!event.showWithoutTime !== next.allDay) return true; + if (next.allDay) { + return !isSameDay(getEventStartDate(event), next.start) + || !isSameDay(getEventDisplayEndDate(event), next.end); + } + const minute = (date: Date) => Math.floor(date.getTime() / 60_000); + return minute(getEventStartDate(event)) !== minute(next.start) + || minute(getEventEndDate(event)) !== minute(next.end); +} + function formatDateInput(d: Date): string { return format(d, "yyyy-MM-dd"); } @@ -587,6 +608,8 @@ export function EventModal({ const start = allDay ? parseISO(startStr) : new Date(startStr); let duration: string; + // The last day of an all-day event, the end of a timed one. + let end: Date; if (allDay) { let inclusiveEnd = new Date(`${endDate}T00:00:00`); @@ -594,9 +617,10 @@ export function EventModal({ inclusiveEnd = new Date(start); } duration = buildAllDayDuration(start, inclusiveEnd); + end = inclusiveEnd; } else { const endStr = `${endDate}T${endTime}:00`; - let end = new Date(endStr); + end = new Date(endStr); if (end <= start) { end = new Date(start.getTime() + 3600000); } @@ -670,10 +694,15 @@ export function EventModal({ // Keeps the event's own organizer - an invitation's sender, the exact // address a series is organized under - and the answers of attendees who - // stay; the user organizes an event that has no organizer yet. + // stay; the user organizes an event that has no organizer yet. Moved by + // the user as its organizer, the others are asked again. The dates are + // those of the event the editor was opened on - one occurrence of a + // series - so a move applies to whichever occurrences the save covers. Object.assign(data, participantFieldsToSave(event, effectiveAttendees, { userEmail: currentUserEmails[0], + userEmails: currentUserEmails, resolveName: resolveContactName, + rescheduled: !!event && changesWhen(event, { allDay, start, end }), })); const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations; diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index 213a02cb1..6fdded676 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -749,6 +749,60 @@ describe('participantFieldsToSave', () => { }); }); +// RFC 5546: an organizer who moves an event asks the attendees again, and +// Stalwart's update to them carries each attendee's stored answer (it adds +// needs-action only where there is none). So when the user, as organizer, +// changes when an event happens, the other attendees go back to +// needs-action; every other save keeps the answers. +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('participantFieldsToSave for a moved event on Stalwart %s', (version) => { + // As each version stores them: 0.16.23 drops an attendee's roles, 1.0 keeps them. + const attendee = (address: string, participationStatus: CalendarParticipant['participationStatus']) => ({ + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, participationStatus, + ...(version === '1.0' ? { roles: { attendee: true } } : {}), + }) as Partial; + const organizer = (address: string) => ({ + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, roles: { owner: true }, participationStatus: 'accepted', + }) as Partial; + const rows = (...emails: string[]) => emails.map((email) => ({ name: '', email })); + const me = { userEmail: 'me@example.com', userEmails: ['me@example.com', 'me-alias@example.com'] }; + + // Organized under the user's alias, in another letter case. + const own = () => makeEvent({ + org: organizer('Me-Alias@Example.com'), + bob: attendee('bob@example.org', 'accepted'), + carol: attendee('carol@example.org', 'declined'), + }, { organizerCalendarAddress: 'mailto:Me-Alias@Example.com' }); + + it('asks the other attendees again when the user organizes it', () => { + const fields = participantFieldsToSave(own(), rows('bob@example.org', 'carol@example.org'), { ...me, rescheduled: true }); + + expect(fields.participants!.org).toMatchObject({ calendarAddress: 'mailto:Me-Alias@Example.com', participationStatus: 'accepted' }); + expect(fields.participants!.bob.participationStatus).toBe('needs-action'); + expect(fields.participants!.carol.participationStatus).toBe('needs-action'); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('keeps the answers when the event is not moved', () => { + const fields = participantFieldsToSave(own(), rows('bob@example.org', 'carol@example.org'), { ...me, rescheduled: false }); + + expect(fields.participants!.bob.participationStatus).toBe('accepted'); + expect(fields.participants!.carol.participationStatus).toBe('declined'); + }); + + it('never asks again for an event someone else organizes', () => { + const invitation = makeEvent({ + boss: organizer('boss@example.org'), + mine: attendee('me@example.com', 'accepted'), + bob: attendee('bob@example.org', 'declined'), + }, { organizerCalendarAddress: 'mailto:boss@example.org' }); + + const fields = participantFieldsToSave(invitation, rows('me@example.com', 'bob@example.org'), { ...me, rescheduled: true }); + + expect(fields.participants!.mine.participationStatus).toBe('accepted'); + expect(fields.participants!.bob.participationStatus).toBe('declined'); + }); +}); + describe('participantLimitOf', () => { it('reads the limit from Stalwart 1.0’s refusal', () => { const refusal = jmapSetError('An event cannot have more than 20 participants.', { diff --git a/lib/__tests__/event-modal-reschedule.test.ts b/lib/__tests__/event-modal-reschedule.test.ts new file mode 100644 index 000000000..a4466e83b --- /dev/null +++ b/lib/__tests__/event-modal-reschedule.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { changesWhen } from '@/components/calendar/event-modal'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// Whether the event editor's dates change when an event happens - what makes +// an organizer's save ask the attendees again. The editor writes the event +// in the user's zone; that alone is no change. + +// 08:00-09:00 in New York (UTC-4 in October), as the server returns it. +const timed = { + id: 'e1', start: '2026-10-12T08:00:00', duration: 'PT1H', timeZone: 'America/New_York', showWithoutTime: false, + utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z', +} as CalendarEvent; + +const allDay = { + id: 'e2', start: '2026-10-12T00:00:00', duration: 'P2D', timeZone: null, showWithoutTime: true, +} as unknown as CalendarEvent; + +const at = (iso: string) => new Date(iso); +const day = (date: string) => new Date(`${date}T00:00:00`); + +describe('changesWhen', () => { + it('is no change at the same instants, whatever zone the form is in', () => { + expect(changesWhen(timed, { allDay: false, start: at('2026-10-12T12:00:00Z'), end: at('2026-10-12T13:00:00Z') })).toBe(false); + }); + + it('ignores the seconds the form cannot show', () => { + const withSeconds = { ...timed, utcStart: '2026-10-12T12:00:30Z', utcEnd: '2026-10-12T13:00:30Z' }; + expect(changesWhen(withSeconds, { allDay: false, start: at('2026-10-12T12:00:00Z'), end: at('2026-10-12T13:00:00Z') })).toBe(false); + }); + + it('is a change when the start or the end moves', () => { + expect(changesWhen(timed, { allDay: false, start: at('2026-10-12T13:00:00Z'), end: at('2026-10-12T14:00:00Z') })).toBe(true); + expect(changesWhen(timed, { allDay: false, start: at('2026-10-12T12:00:00Z'), end: at('2026-10-12T13:30:00Z') })).toBe(true); + }); + + it('is a change when the event turns all-day or timed', () => { + expect(changesWhen(timed, { allDay: true, start: day('2026-10-12'), end: day('2026-10-12') })).toBe(true); + expect(changesWhen(allDay, { allDay: false, start: day('2026-10-12'), end: day('2026-10-13') })).toBe(true); + }); + + it('compares the first and last day of an all-day event', () => { + expect(changesWhen(allDay, { allDay: true, start: day('2026-10-12'), end: day('2026-10-13') })).toBe(false); + expect(changesWhen(allDay, { allDay: true, start: day('2026-10-12'), end: day('2026-10-14') })).toBe(true); + expect(changesWhen(allDay, { allDay: true, start: day('2026-10-13'), end: day('2026-10-14') })).toBe(true); + }); +}); diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index 9e79beb3d..152a5bf8b 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -272,12 +272,14 @@ function participantsByAddress( * once. `existing` is the event's current map. An address already in it * keeps its participant id and its answer, so a save neither resets the * attendees' answers - the user's own on an invitation included - nor leaves - * a recurrence override pointing at a participant id that is gone. + * a recurrence override pointing at a participant id that is gone. With + * `askAgain` every attendee but the organizer answers anew (needs-action). */ export function buildParticipantMap( organizer: { name: string; email: string }, attendees: { name: string; email: string }[], existing?: CalendarEvent['participants'] | null, + options: { askAgain?: boolean } = {}, ): Record> { const participants: Record> = {}; const known = participantsByAddress(existing); @@ -322,7 +324,7 @@ export function buildParticipantMap( email, calendarAddress: `mailto:${email}`, roles: { attendee: true }, - participationStatus: known.get(key)?.status ?? 'needs-action', + participationStatus: (options.askAgain ? undefined : known.get(key)?.status) ?? 'needs-action', scheduleAgent: 'server', expectReply: true, kind: 'individual', @@ -348,8 +350,12 @@ function eventOrganizer(event: CalendarEvent): { name: string; email: string } | export interface ParticipantSaveContext { /** The user's first calendar address: the organizer of an event that has none yet. */ userEmail: string | undefined; + /** All of the user's calendar addresses (identities, aliases): whether the user organizes the event. */ + userEmails?: string[]; /** A name for an organizer the event data carries none for (the contact card). */ resolveName?: (email: string) => string | undefined; + /** The save changes when the event happens: its start or end instant, or whether it is all-day. */ + rescheduled?: boolean; } /** @@ -361,7 +367,11 @@ export interface ParticipantSaveContext { * occurrences). Only an event without an organizer gets the user, and only * then is `organizerCalendarAddress` written: Stalwart derives the * iCalendar ORGANIZER solely from it, so without one no invitations go out. - * Participants that stay keep their id and answer (buildParticipantMap). + * Participants that stay keep their id and answer (buildParticipantMap), + * unless the user as the organizer moves the event: then the other + * attendees are asked again, as RFC 5546 has an organizer do when an + * event is rescheduled, and the server's update asks them with + * needs-action. * * Without attendees, an event that had participants loses them, its * organizer and the retired `replyTo` older releases wrote. @@ -374,6 +384,8 @@ export function participantFieldsToSave( const { userEmail, resolveName } = context; if (attendees.length > 0 && userEmail) { const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail }; + const userAddresses = [userEmail, ...(context.userEmails ?? [])].map((address) => address.trim().toLowerCase()); + const askAgain = !!context.rescheduled && userAddresses.includes(organizer.email.toLowerCase()); const fields: Partial> = { participants: buildParticipantMap( // A new event has no participants yet, so it would store an empty @@ -381,6 +393,7 @@ export function participantFieldsToSave( { name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email }, attendees, event?.participants, + { askAgain }, ) as Record, }; if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`;