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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user