diff --git a/web/src/store/__tests__/event-scope.test.ts b/web/src/store/__tests__/event-scope.test.ts new file mode 100644 index 0000000..70f5ae8 --- /dev/null +++ b/web/src/store/__tests__/event-scope.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { eventIdForScope, isOccurrence, useCalendar } from "@/store/calendar"; +import type { CalendarEvent, JmapSession } from "@/jmap/types"; + +/** + * Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set` + * refused one outright, so a mutation aimed at the wrong id of an expanded + * occurrence arrived as a toast rather than as data loss. + * + * 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a + * destroy that meant the series removes one date and reports success, under a + * dialog that said "Delete all occurrences?". The resolution therefore lives in + * the store behind a required `scope`, and these tests are what stops it + * drifting back out to the callers. + */ + +/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */ +const OCCURRENCE: CalendarEvent = { + id: "iaaaaas", + baseEventId: "i", + "@type": "Event", + uid: "u1", + calendarIds: { c1: true }, + start: "2026-09-02T09:00:00", + duration: "PT30M", + recurrenceId: "2026-09-02T09:00:00", + participants: { + me: { "@type": "Participant", calendarAddress: "mailto:me@example.org", participationStatus: "needs-action", roles: { attendee: true } }, + }, +} as unknown as CalendarEvent; + +/** A one-off, which an expanded query still hands back with a base of its own. */ +const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent; + +/** A master, fetched by id rather than expanded. */ +const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent; + +interface SetCall { update?: Record; destroy?: string[] } + +function server() { + const calls: SetCall[] = []; + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "CalendarEvent/set") { + calls.push({ update: args.update as Record, destroy: args.destroy as string[] }); + return [name, { + accountId: "a1", oldState: "1", newState: "2", + updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])), + destroyed: (args.destroy ?? []) as string[], + notUpdated: {}, notDestroyed: {}, + }, id]; + } + return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id]; + }); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return calls; +} + +beforeEach(() => { + client.session = { + capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} }, + accounts: {}, + primaryAccounts: {}, + state: "s1", + } as unknown as JmapSession; + useCalendar.setState({ + accountId: "a1", + available: true, + calendars: {}, + events: { [OCCURRENCE.id]: OCCURRENCE }, + ranges: {}, + identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:me@example.org", sendTo: {}, isDefault: true }], + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("eventIdForScope", () => { + it("walks an occurrence up to its master for the series", () => { + expect(eventIdForScope(OCCURRENCE, "series")).toBe("i"); + }); + it("sends the instance as it came for a single occurrence", () => { + expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas"); + }); + it("resolves a master to itself under either scope", () => { + expect(eventIdForScope(MASTER, "series")).toBe("i"); + expect(eventIdForScope(MASTER, "occurrence")).toBe("i"); + }); + it("treats a one-off's synthetic id as a series id, because its base is real", () => { + // An expanded query gives a one-off an instance id over a different base. + // Stalwart resolves a synthetic id on a component that is neither recurrent + // nor an override back to the base event, so both scopes are safe here — + // but only `series` sends the id that is unambiguously the event. + expect(eventIdForScope(ONE_OFF, "series")).toBe("i"); + expect(isOccurrence(ONE_OFF)).toBe(true); + }); + it("does not call a master an occurrence", () => { + expect(isOccurrence(MASTER)).toBe(false); + expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false); + }); +}); + +describe("destroyEvent", () => { + it("sends the master id for a series, never the synthetic one", async () => { + const calls = server(); + await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series"); + expect(calls[0]!.destroy).toEqual(["i"]); + expect(calls[0]!.destroy).not.toContain("iaaaaas"); + }); + + it("sends the synthetic id for a single occurrence", async () => { + const calls = server(); + await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence"); + expect(calls[0]!.destroy).toEqual(["iaaaaas"]); + }); + + it("drops the occurrence from the cache without evicting the master", async () => { + server(); + useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } }); + await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence"); + expect(useCalendar.getState().events.iaaaaas).toBeUndefined(); + expect(useCalendar.getState().events.i).toBeDefined(); + }); +}); + +describe("updateEvent", () => { + it("patches the master for a series", async () => { + const calls = server(); + await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series"); + expect(Object.keys(calls[0]!.update!)).toEqual(["i"]); + }); + + it("patches the instance for a single occurrence", async () => { + const calls = server(); + await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence"); + expect(Object.keys(calls[0]!.update!)).toEqual(["iaaaaas"]); + }); +}); + +describe("rsvp", () => { + it("answers for the series even when handed an occurrence", async () => { + // The patch itself survives either scope: `participationStatus` is one of + // the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an + // instance would quietly mean "only that day" and nothing would say so. + const calls = server(); + await useCalendar.getState().rsvp(OCCURRENCE, "accepted"); + expect(Object.keys(calls[0]!.update!)).toEqual(["i"]); + expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" }); + }); + + it("refuses when the signed-in identity is not a participant", async () => { + server(); + useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:someone-else@example.org", sendTo: {}, isDefault: true }] }); + await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index d723d08..2ad1f5b 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -46,6 +46,43 @@ export const CALENDAR_PROPS = [ "myRights", ]; +/** + * Which of an event's two ids a mutation means. + * + * `CalendarEvent/query` runs with `expandRecurrences`, so an occurrence arrives + * carrying a synthetic `id` of its own *and* a `baseEventId` pointing at the + * master it was expanded from. Sending one where the other was meant is not a + * distinction the server will make for us: + * + * - Through 0.16.19 a synthetic id was refused outright — *"Updating synthetic + * ids is not yet supported"* — so a slip was loud and arrived as a toast. + * - 0.16.20 accepts it, and writes a `recurrenceOverrides` entry instead. A + * destroy that meant the series now removes one date and reports success, + * under a dialog that said "Delete all occurrences?". + * + * So the choice is named and required rather than left to each caller to + * remember a `??`. There is exactly one place that turns an event into an id, + * and it is below. + */ +export type EventScope = "series" | "occurrence"; + +/** + * The id to send for `scope`. + * + * `series` walks up to the master; `occurrence` sends the instance as it came. + * A one-off is safe either way — it has a synthetic id like everything an + * expanded query returns, and Stalwart resolves a synthetic id on a component + * that is neither recurrent nor an override back to the base event itself. + */ +export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id { + return scope === "series" ? (event.baseEventId ?? event.id) : event.id; +} + +/** Whether this object is an expanded occurrence rather than a master. */ +export function isOccurrence(event: CalendarEvent): boolean { + return event.baseEventId != null && event.baseEventId !== event.id; +} + /** A calendar somebody else shared, and the account it lives in. */ export interface SharedCalendar { accountId: Id; @@ -85,9 +122,9 @@ interface CalendarState { instancesIn(start: Date, end: Date): EventInstance[]; getEvent(id: Id): Promise; createEvent(event: Partial, calendarId: Id, sendInvites: boolean): Promise; - updateEvent(id: Id, patch: Record, sendInvites: boolean): Promise; - destroyEvent(id: Id, sendInvites: boolean): Promise; - rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise; + updateEvent(event: CalendarEvent, patch: Record, sendInvites: boolean, scope: EventScope): Promise; + destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise; + rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise; createCalendar(data: Partial): Promise; updateCalendar(id: Id, patch: Partial): Promise; destroyCalendar(id: Id): Promise; @@ -359,39 +396,46 @@ export const useCalendar = create((set, get) => ({ return res.created!.e!.id; }, - async updateEvent(id, patch, sendInvites) { + async updateEvent(event, patch, sendInvites, scope) { const accountId = get().accountId!; + const id = eventIdForScope(event, scope); const res = await client.call("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites }); const err = res.notUpdated?.[id]; if (err) throw new Error(setErrorMessage(err)); get().invalidate(); }, - async destroyEvent(id, sendInvites) { + async destroyEvent(event, sendInvites, scope) { const accountId = get().accountId!; + const id = eventIdForScope(event, scope); const res = await client.call("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites }); const err = res.notDestroyed?.[id]; if (err) throw new Error(setErrorMessage(err)); set((s) => { const events = { ...s.events }; + // Drop both ids: the one that was sent, and the object as the caller + // held it. An occurrence destroy leaves the master alone on purpose. delete events[id]; + if (scope === "occurrence") delete events[event.id]; return { events }; }); get().invalidate(); }, - async rsvp(id, status, comment) { - const ev = get().events[id] ?? (await get().getEvent(id)); - if (!ev) throw new Error("Event not found"); - id = ev.baseEventId ?? id; - const mine = myParticipantKeys(ev, get().identities); + async rsvp(event, status, comment) { + const mine = myParticipantKeys(event, get().identities); if (!mine.length) throw new Error("You are not a participant of this event"); const patch: Record = {}; for (const k of mine) { patch[`participants/${k}/participationStatus`] = status; if (comment) patch[`participants/${k}/participationComment`] = comment; } - await get().updateEvent(id, patch, true); + // Answering for the series, not for one date. The patch itself survives + // either scope -- `participants/{key}/participationStatus` is one of the + // pointers 0.16.20 allows on an occurrence -- so this would silently mean + // "only that day" if it were aimed at an instance. Accepting an invitation + // means accepting the series. + await get().updateEvent(event, patch, true, "series"); }, async createCalendar(data) { @@ -436,6 +480,14 @@ export const useCalendar = create((set, get) => ({ return res.list ?? []; }, + /** + * The event with this uid, as a master rather than an occurrence. + * + * The query deliberately omits `expandRecurrences`, so what comes back is the + * stored event and `id` is a real id. Callers rely on that — `InviteCard` + * removes a cancelled event by handing this straight to `destroyEvent` — so + * it is a property of this method, not an accident of the default. + */ async findByUid(uid) { const accountId = get().accountId; if (!accountId) return null; diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index a544594..d9c8b08 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -60,14 +60,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: const { inst } = ctx; const ev = inst.event; - const baseId = ev.baseEventId ?? ev.id; const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar; const currentCat = categoryOf(ev, categories); const participants = Object.keys(ev.participants ?? {}).length; const patch = async (p: Record, msg: string) => { try { - await cal.updateEvent(baseId, p, false); + await cal.updateEvent(ev, p, false, "series"); toast.success(msg); } catch (err) { toast.error((err as Error).message); @@ -92,7 +91,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: const recurring = isRecurring(ev); if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return; try { - await cal.destroyEvent(baseId, participants > 1); + await cal.destroyEvent(ev, participants > 1, "series"); toast.success("Event deleted"); } catch (err) { toast.error((err as Error).message); diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index 4602596..91133bb 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -178,7 +178,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE const patch: Record = {}; for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v; if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true }; - await cal.updateEvent(ev.id, patch, invites); + // `ev` is the master: EventEditor resolves `baseEventId` when it opens + // on an occurrence, so the whole series is what this form edits. + await cal.updateEvent(ev, patch, invites, "series"); toast.success("Event updated"); } else { const clean: Record = {}; diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 564d5bd..e2ffe20 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -22,7 +22,6 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined; const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k))); const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar; - const baseId = ev.baseEventId ?? ev.id; const location = Object.values(ev.locations ?? {})[0]; const vloc = Object.values(ev.virtualLocations ?? {})[0]; const alerts = Object.values(ev.alerts ?? {}); @@ -34,7 +33,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns if (!ok) return; setBusy(true); try { - await cal.destroyEvent(baseId, participants.length > 1); + await cal.destroyEvent(ev, participants.length > 1, "series"); toast.success("Event deleted"); onClose(); } catch (err) { @@ -47,7 +46,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns const rsvp = async (status: "accepted" | "tentative" | "declined") => { setBusy(true); try { - await cal.rsvp(baseId, status); + await cal.rsvp(ev, status); toast.success("Response sent"); onClose(); } catch (err) { diff --git a/web/src/views/mail/InviteCard.tsx b/web/src/views/mail/InviteCard.tsx index 937576f..a242492 100644 --- a/web/src/views/mail/InviteCard.tsx +++ b/web/src/views/mail/InviteCard.tsx @@ -54,7 +54,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart target = await cal.getEvent(id); } if (!target) throw new Error("Could not add the event to your calendar"); - await cal.rsvp(target.id, status); + await cal.rsvp(target, status); setExisting(await cal.getEvent(target.id)); toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative"); } catch (err) { @@ -115,7 +115,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart )} {method === "CANCEL" && existing && (
- +
)} {email.id}