Merge pull request #135 from Coffey-Labs/calendar-event-scope

Resolve the base event id in the calendar store, not at the call sites
This commit is contained in:
Coffey Labs
2026-08-30 21:29:59 -07:00
committed by GitHub
6 changed files with 235 additions and 20 deletions
+163
View File
@@ -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:[email protected]", 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<string, unknown>; 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, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "CalendarEvent/set") {
calls.push({ update: args.update as Record<string, unknown>, 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:[email protected]", 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:[email protected]", sendTo: {}, isDefault: true }] });
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
});
});
+63 -11
View File
@@ -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<CalendarEvent | null>;
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<void>;
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
createCalendar(data: Partial<Calendar>): Promise<Id>;
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
destroyCalendar(id: Id): Promise<void>;
@@ -359,39 +396,46 @@ export const useCalendar = create<CalendarState>((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<SetResponse>("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<SetResponse>("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<string, unknown> = {};
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<CalendarState>((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;
@@ -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<string, unknown>, 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);
+3 -1
View File
@@ -178,7 +178,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const patch: Record<string, unknown> = {};
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<string, unknown> = {};
+2 -3
View File
@@ -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) {
+2 -2
View File
@@ -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 && (
<div className="rsvp">
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
</div>
)}
<span className="sr-only">{email.id}</span>