Skip to content

Commit 396323b

Browse files
authored
fix(google): don't notify when you cancel your own recurring occurrence (#352)
Cancelling a single occurrence of a recurring event you own still flipped the event's thread unread, so the person who did the cancelling got notified about their own action. The connector already had a self-initiated check, but it read `organizer` and `attendees` off the cancelled instance. The Calendar API only guarantees `id`, `recurringEventId` and `originalStartTime` on a cancelled exception, so those fields are essentially never present and the check could not fire on the occurrence path. Who is allowed to cancel an occurrence is a property of the series, not of the exception, so resolve it from the recurring master instead. The master carries the organizer and attendee list; the lookup is memoized per series for each page of events, only runs on incremental syncs, and resolves to "notify" on any failure, so a cancellation by someone else is never suppressed. Also fixes the batch merge dropping a cancelled occurrence's read intent when its master lands in the same page of events — which is the usual shape, since cancelling an occurrence also bumps the master. `cancellationWasSelfInitiatedFn` now returns `boolean | null`, where `null` means the payload carried no signal either way.
1 parent 21bee5d commit 396323b

2 files changed

Lines changed: 336 additions & 25 deletions

File tree

‎connectors/google/src/calendar/sync.test.ts‎

Lines changed: 220 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,12 +1437,12 @@ describe("cancellationWasSelfInitiatedFn", () => {
14371437
).toBe(false);
14381438
});
14391439

1440-
it("does not conclude self-initiated from a sparse payload (no organizer or attendees)", () => {
1441-
// Google only guarantees id/status/updated on a cancelled event. Absence of
1442-
// attendees is "unknown", not "solo" — never suppress on missing data.
1440+
it("reports 'unknown' for a sparse payload (no organizer or attendees)", () => {
1441+
// Google only guarantees id/status on a cancelled event. Absence of
1442+
// attendees is "unknown", not "solo" — never conclude from missing data.
14431443
expect(
14441444
cancellationWasSelfInitiatedFn({ id: "e", status: "cancelled" })
1445-
).toBe(false);
1445+
).toBeNull();
14461446
});
14471447
});
14481448

@@ -1538,6 +1538,222 @@ describe("processCalendarEventsFn — self-initiated cancellations", () => {
15381538
});
15391539
});
15401540

1541+
describe("processCalendarEventsFn — sparse cancelled occurrences", () => {
1542+
// The Calendar API only guarantees `id`, `recurringEventId` and
1543+
// `originalStartTime` on a cancelled exception, so the instance payload
1544+
// carries none of the organizer/attendee signal the self-initiated check
1545+
// needs. Whether the user could have cancelled the occurrence is a property
1546+
// of the SERIES, so it has to be resolved from the recurring master.
1547+
afterEach(() => {
1548+
vi.unstubAllGlobals();
1549+
});
1550+
1551+
const calendarId = "user@example.com";
1552+
const futureStart = () => new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
1553+
1554+
/** A cancelled exception with only the fields Google guarantees. */
1555+
function sparseCancelledInstance(masterId: string, start: Date) {
1556+
return {
1557+
id: `${masterId}_${start.toISOString()}`,
1558+
recurringEventId: masterId,
1559+
originalStartTime: { dateTime: start.toISOString() },
1560+
status: "cancelled" as const,
1561+
updated: new Date().toISOString(),
1562+
};
1563+
}
1564+
1565+
/**
1566+
* Stub fetch so `events/<id>` GETs resolve to `masters[id]` (or the given
1567+
* status when absent) and everything else returns an empty events page.
1568+
* Returns the mock so tests can assert on call counts.
1569+
*/
1570+
function stubFetchWithMasters(
1571+
masters: Record<string, object>,
1572+
missingStatus = 404
1573+
) {
1574+
const fetchMock = vi.fn(async (url: string | URL) => {
1575+
const href = typeof url === "string" ? url : url.toString();
1576+
const match = href.match(/\/events\/([^?]+)/);
1577+
if (match) {
1578+
const id = decodeURIComponent(match[1]);
1579+
const master = masters[id];
1580+
if (!master) {
1581+
return new Response("not found", { status: missingStatus });
1582+
}
1583+
return new Response(JSON.stringify(master), {
1584+
status: 200,
1585+
headers: { "Content-Type": "application/json" },
1586+
});
1587+
}
1588+
return makeEventsResponse([]);
1589+
});
1590+
vi.stubGlobal("fetch", fetchMock);
1591+
return fetchMock;
1592+
}
1593+
1594+
function seedHost() {
1595+
const host = makeFakeHost({ calendarId });
1596+
host.store.set(
1597+
`first_sync_at_${calendarId}`,
1598+
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
1599+
);
1600+
return host;
1601+
}
1602+
1603+
it("marks the cancellation read when the recurring master is self-organized", async () => {
1604+
const host = seedHost();
1605+
const start = futureStart();
1606+
stubFetchWithMasters({
1607+
"master-self": {
1608+
id: "master-self",
1609+
iCalUID: "series@google.com",
1610+
status: "confirmed",
1611+
summary: "Church",
1612+
organizer: { email: "user@example.com", self: true },
1613+
},
1614+
});
1615+
1616+
await processCalendarEventsFn(
1617+
host,
1618+
[sparseCancelledInstance("master-self", start)],
1619+
calendarId,
1620+
false
1621+
);
1622+
1623+
const saved = host.savedLinks.flat();
1624+
expect(saved).toHaveLength(1);
1625+
// Still annotates the series with the cancellation …
1626+
expect(
1627+
saved[0].notes?.some((n) =>
1628+
(n as { key?: string }).key?.startsWith("cancellation-")
1629+
)
1630+
).toBe(true);
1631+
// … but does not flip it unread for the person who cancelled it.
1632+
expect(saved[0].unread).toBe(false);
1633+
});
1634+
1635+
it("keeps the cancellation unread when someone else organizes the series", async () => {
1636+
const host = seedHost();
1637+
const start = futureStart();
1638+
stubFetchWithMasters({
1639+
"master-other": {
1640+
id: "master-other",
1641+
iCalUID: "series@google.com",
1642+
status: "confirmed",
1643+
summary: "Standup",
1644+
organizer: { email: "boss@example.com", self: false },
1645+
attendees: [
1646+
{ email: "user@example.com", self: true },
1647+
{ email: "boss@example.com", organizer: true },
1648+
],
1649+
},
1650+
});
1651+
1652+
await processCalendarEventsFn(
1653+
host,
1654+
[sparseCancelledInstance("master-other", start)],
1655+
calendarId,
1656+
false
1657+
);
1658+
1659+
const saved = host.savedLinks.flat();
1660+
expect(saved).toHaveLength(1);
1661+
expect(saved[0].unread).toBeUndefined();
1662+
});
1663+
1664+
it("keeps the cancellation unread when the master cannot be fetched", async () => {
1665+
const host = seedHost();
1666+
const start = futureStart();
1667+
stubFetchWithMasters({}, 500);
1668+
1669+
await processCalendarEventsFn(
1670+
host,
1671+
[sparseCancelledInstance("master-gone", start)],
1672+
calendarId,
1673+
false
1674+
);
1675+
1676+
const saved = host.savedLinks.flat();
1677+
expect(saved).toHaveLength(1);
1678+
expect(saved[0].unread).toBeUndefined();
1679+
});
1680+
1681+
it("fetches each master once per page of events", async () => {
1682+
const host = seedHost();
1683+
const start = futureStart();
1684+
const fetchMock = stubFetchWithMasters({
1685+
"master-self": {
1686+
id: "master-self",
1687+
iCalUID: "series@google.com",
1688+
status: "confirmed",
1689+
organizer: { email: "user@example.com", self: true },
1690+
},
1691+
});
1692+
1693+
await processCalendarEventsFn(
1694+
host,
1695+
[
1696+
sparseCancelledInstance("master-self", start),
1697+
sparseCancelledInstance(
1698+
"master-self",
1699+
new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000)
1700+
),
1701+
sparseCancelledInstance(
1702+
"master-self",
1703+
new Date(start.getTime() + 14 * 24 * 60 * 60 * 1000)
1704+
),
1705+
],
1706+
calendarId,
1707+
false
1708+
);
1709+
1710+
const eventGets = fetchMock.mock.calls.filter(([url]) =>
1711+
String(url).includes("/events/master-self")
1712+
);
1713+
expect(eventGets).toHaveLength(1);
1714+
1715+
const saved = host.savedLinks.flat();
1716+
expect(saved).toHaveLength(1);
1717+
expect(saved[0].unread).toBe(false);
1718+
});
1719+
1720+
it("keeps the read intent when the master arrives in the same page", async () => {
1721+
// Cancelling an occurrence also bumps the master, so both land in one
1722+
// incremental page. The master is the link that carries `schedules`, and
1723+
// merging must not drop the occurrence link's read intent.
1724+
const host = seedHost();
1725+
const start = futureStart();
1726+
stubFetchWithMasters({});
1727+
1728+
const master = {
1729+
id: "master-self",
1730+
iCalUID: "series@google.com",
1731+
status: "confirmed" as const,
1732+
summary: "Church",
1733+
updated: new Date().toISOString(),
1734+
start: { dateTime: start.toISOString() },
1735+
end: { dateTime: new Date(start.getTime() + 3_600_000).toISOString() },
1736+
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=SU"],
1737+
organizer: { email: "user@example.com", self: true },
1738+
};
1739+
const cancelled = {
1740+
...sparseCancelledInstance("master-self", start),
1741+
// Both links must resolve to the same canonical source to be coalesced,
1742+
// and the point of this test is the merge — so give the instance the
1743+
// series iCalUID and enough signal to conclude self-initiated on its own.
1744+
iCalUID: "series@google.com",
1745+
organizer: { email: "user@example.com", self: true },
1746+
};
1747+
1748+
// Master first — the ordering that previously discarded `unread`.
1749+
await processCalendarEventsFn(host, [master, cancelled], calendarId, false);
1750+
1751+
const saved = host.savedLinks.flat();
1752+
expect(saved).toHaveLength(1);
1753+
expect(saved[0].unread).toBe(false);
1754+
});
1755+
});
1756+
15411757
describe("processCalendarEventsFn — message-model note audiences", () => {
15421758
afterEach(() => {
15431759
vi.unstubAllGlobals();

0 commit comments

Comments
 (0)