Skip to content

Commit cff0bfd

Browse files
committed
🐛 Fix: map gcal recurrence correctly
change to include either rule OR recurringEventId (not both)
1 parent d04ae0c commit cff0bfd

File tree

10 files changed

+284
-81
lines changed

10 files changed

+284
-81
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Origin } from "@core/constants/core.constants";
2+
import { Priorities } from "@core/constants/core.constants";
3+
import { Summary_SeriesChange_Compass } from "../recurrence.types";
4+
import { shouldSplitSeries } from "./recurrence.manager";
5+
6+
describe("shouldSplitSeries", () => {
7+
it("should return true if the base event and new base event are different", () => {
8+
const changes: Summary_SeriesChange_Compass = {
9+
action: "UPDATE_SERIES",
10+
baseEvent: {
11+
description: "",
12+
endDate: "2025-04-02T10:15:00-05:00",
13+
isAllDay: false,
14+
isSomeday: false,
15+
gEventId: "439ntgoijfbls638nmrprgl6no",
16+
origin: Origin.GOOGLE_IMPORT,
17+
priority: Priorities.UNASSIGNED,
18+
recurrence: {
19+
rule: ["RRULE:FREQ=DAILY;UNTIL=20250403T045959Z"],
20+
},
21+
startDate: "2025-04-02T09:00:00-05:00",
22+
title: "r1",
23+
updatedAt: "2025-04-03T13:39:59.196Z",
24+
user: "67ee8f9dda653b114194d127",
25+
},
26+
newBaseEvent: {
27+
description: "",
28+
endDate: "2025-04-03T10:15:00-05:00",
29+
isAllDay: false,
30+
isSomeday: false,
31+
gEventId: "439ntgoijfbls638nmrprgl6no_R20250403T140000",
32+
origin: Origin.GOOGLE_IMPORT,
33+
priority: Priorities.UNASSIGNED,
34+
recurrence: {
35+
rule: ["RRULE:FREQ=DAILY"],
36+
},
37+
startDate: "2025-04-03T09:00:00-05:00",
38+
title: "r1-i",
39+
updatedAt: "2025-04-03T13:39:59.196Z",
40+
user: "67ee8f9dda653b114194d127",
41+
},
42+
deleteFrom: "DAILY;UNTIL",
43+
};
44+
45+
const result = shouldSplitSeries(changes);
46+
expect(result).toBe(true);
47+
});
48+
});

packages/backend/src/event/services/recurrence/manage/recurrence.manager.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
Schema_Event_Recur_Base,
3+
Schema_Event_Recur_Instance,
4+
} from "@core/types/event.types";
15
import { GenericError } from "@backend/common/constants/error.constants";
26
import { error } from "@backend/common/errors/handlers/error.handler";
37
import { RecurringEventProcessor } from "../process/processor.interface";
@@ -16,13 +20,15 @@ export class RecurringEventManager {
1620

1721
/**
1822
* Main entrypoint that handles different recurring event actions
19-
* @param input The action data containing events and action type
23+
* @param changes The action data containing events and action type
2024
*/
21-
async handleAction(input: Summary_SeriesChange_Compass) {
25+
async handleAction(changes: Summary_SeriesChange_Compass) {
2226
const { action, baseEvent, newBaseEvent, modifiedInstance, deleteFrom } =
23-
input;
27+
changes;
28+
29+
console.log("handling changes:");
30+
console.log(JSON.stringify(changes, null, 2));
2431

25-
console.log(input);
2632
switch (action) {
2733
case "CREATE_SERIES":
2834
if (baseEvent) {
@@ -38,14 +44,16 @@ export class RecurringEventManager {
3844

3945
case "UPDATE_SERIES":
4046
// Series update with split (this and future)
41-
if (baseEvent && newBaseEvent && modifiedInstance) {
47+
if (shouldSplitSeries(changes)) {
48+
console.log("++ updating series with split");
4249
return this.processor.updateSeriesWithSplit(
43-
baseEvent,
44-
modifiedInstance,
50+
baseEvent as Schema_Event_Recur_Base,
51+
modifiedInstance as Schema_Event_Recur_Instance,
4552
);
4653
}
4754
// Update entire series
4855
else if (baseEvent && newBaseEvent) {
56+
console.log("++ updating entire series");
4957
return this.processor.updateEntireSeries(baseEvent, newBaseEvent);
5058
}
5159
break;
@@ -74,3 +82,23 @@ export class RecurringEventManager {
7482
);
7583
}
7684
}
85+
86+
export const shouldSplitSeries = (changes: Summary_SeriesChange_Compass) => {
87+
console.log("++ checking if shouldSplitSeries using these changes:");
88+
console.log(JSON.stringify(changes, null, 2));
89+
90+
// If we have a base event with an UNTIL rule and a new base event with a rule but no UNTIL,
91+
// we need to split the series
92+
const baseEventHasUntil =
93+
changes.baseEvent?.recurrence?.rule?.[0]?.includes("UNTIL");
94+
const newBaseEventHasRule =
95+
changes.newBaseEvent?.recurrence?.rule?.[0] !== undefined;
96+
const newBaseEventHasNoUntil =
97+
!changes.newBaseEvent?.recurrence?.rule?.[0]?.includes("UNTIL");
98+
99+
if (baseEventHasUntil && newBaseEventHasRule && newBaseEventHasNoUntil) {
100+
return true;
101+
}
102+
103+
return false;
104+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Action_Series } from "../recurrence.types";
2+
import { GCalRecurringEventMapper } from "./gcal.recur.map";
3+
4+
describe("GCalRecurringEventMapper", () => {
5+
it("infers the correct action", () => {
6+
const changesFromGcal = {
7+
action: "UPDATE_SERIES" as Action_Series,
8+
baseEvent: {
9+
kind: "calendar#event",
10+
etag: '"3487376669522302"',
11+
id: "7q78dn5t1eu6ikjq5mj4q7s93d",
12+
status: "confirmed",
13+
htmlLink:
14+
"https://www.google.com/calendar/event?eid=N3E3OGRuNXQxZXU2aWtqcTVtajRxN3M5M2RfMjAyNTA0MDJUMTIwMDAwWiBsYW5jZS5lc3NlcnRAbQ",
15+
created: "2025-04-03T13:49:29.000Z",
16+
updated: "2025-04-03T13:52:14.761Z",
17+
summary: "r1",
18+
creator: { email: "lance.essert@gmail.com", self: true },
19+
organizer: { email: "lance.essert@gmail.com", self: true },
20+
start: {
21+
dateTime: "2025-04-02T07:00:00-05:00",
22+
timeZone: "America/Chicago",
23+
},
24+
end: {
25+
dateTime: "2025-04-02T08:00:00-05:00",
26+
timeZone: "America/Chicago",
27+
},
28+
recurrence: ["RRULE:FREQ=DAILY;UNTIL=20250403T045959Z"],
29+
iCalUID: "7q78dn5t1eu6ikjq5mj4q7s93d@google.com",
30+
sequence: 0,
31+
reminders: { useDefault: true },
32+
eventType: "default",
33+
},
34+
newBaseEvent: {
35+
kind: "calendar#event",
36+
etag: '"3487376669522302"',
37+
id: "7q78dn5t1eu6ikjq5mj4q7s93d_R20250403T120000",
38+
status: "confirmed",
39+
htmlLink:
40+
"https://www.google.com/calendar/event?eid=N3E3OGRuNXQxZXU2aWtqcTVtajRxN3M5M2RfMjAyNTA0MDNUMTIwMDAwWiBsYW5jZS5lc3NlcnRAbQ",
41+
created: "2025-04-03T13:49:29.000Z",
42+
updated: "2025-04-03T13:52:14.761Z",
43+
summary: "r1-i",
44+
creator: { email: "lance.essert@gmail.com", self: true },
45+
organizer: { email: "lance.essert@gmail.com", self: true },
46+
start: {
47+
dateTime: "2025-04-03T07:00:00-05:00",
48+
timeZone: "America/Chicago",
49+
},
50+
end: {
51+
dateTime: "2025-04-03T08:00:00-05:00",
52+
timeZone: "America/Chicago",
53+
},
54+
recurrence: ["RRULE:FREQ=DAILY"],
55+
iCalUID: "7q78dn5t1eu6ikjq5mj4q7s93d_R20250403T120000@google.com",
56+
sequence: 0,
57+
reminders: { useDefault: true },
58+
eventType: "default",
59+
},
60+
deleteFrom: "DAILY;UNTIL",
61+
hasInstances: false,
62+
};
63+
64+
const mapper = new GCalRecurringEventMapper("123", changesFromGcal);
65+
const result = mapper.inferChanges();
66+
expect(result.action).toEqual("UPDATE_SERIES");
67+
});
68+
});

packages/backend/src/event/services/recurrence/map/gcal.recur.map.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,25 @@ export class GCalRecurringEventMapper {
2222
}
2323

2424
/**
25-
* Maps Google Calendar events to our schemas
25+
* Infer changes from Google Calendar events
2626
*/
27-
mapEvents(): Summary_SeriesChange_Compass {
28-
const schemaEvents = this.mapActionAnalysisToEvents();
29-
const input = {
27+
inferChanges(): Summary_SeriesChange_Compass {
28+
const schemaEvents = this.mapGcalChangesToCompassChanges();
29+
const compassChanges = {
3030
action: this.actionAnalysis.action,
3131
...schemaEvents,
3232
deleteFrom: this.actionAnalysis.deleteFrom,
3333
};
3434

35-
return input;
35+
console.log("++ compassChanges (returning these from mapper):");
36+
console.log(JSON.stringify(compassChanges));
37+
return compassChanges;
3638
}
3739

3840
/**
39-
* Maps Google Calendar action analysis to Schema_Event objects
41+
* Maps Google Calendar changes to Compass changes
4042
*/
41-
private mapActionAnalysisToEvents() {
43+
private mapGcalChangesToCompassChanges() {
4244
const result: {
4345
baseEvent?: Schema_Event_Recur_Base;
4446
newBaseEvent?: Schema_Event_Recur_Base;

packages/backend/src/event/services/recurrence/parse/recur.gcal.parse.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import { deleteSingleEventPayloads } from "@backend/__tests__/mocks.gcal/fixture
44
import { deleteThisAndFollowingPayloads } from "@backend/__tests__/mocks.gcal/fixtures/recurring/delete/this-and-following";
55
import { editAllPayloads } from "@backend/__tests__/mocks.gcal/fixtures/recurring/edit/all";
66
import { editSingleEventPayloads } from "@backend/__tests__/mocks.gcal/fixtures/recurring/edit/single";
7-
import { editThisAndFollowingPayloads } from "@backend/__tests__/mocks.gcal/fixtures/recurring/edit/this-and-following";
8-
import { determineNextAction } from "@backend/event/services/recurrence/parse/recur.gcal.parse";
7+
import { thisAndFollowingPayloads } from "@backend/__tests__/mocks.gcal/fixtures/recurring/edit/this-and-following";
8+
import { inferGcalChange } from "@backend/event/services/recurrence/parse/recur.gcal.parse";
99

1010
describe("Gcal Recurring Event Payload Analysis", () => {
1111
describe("Create", () => {
1212
it("should return CREATE_SERIES after new recurring event creation", () => {
13-
const analysis = determineNextAction(
13+
const analysis = inferGcalChange(
1414
createNewRecurringEventPayload.items || [],
1515
);
1616
expect(analysis.action).toBe("CREATE_SERIES");
@@ -22,16 +22,16 @@ describe("Gcal Recurring Event Payload Analysis", () => {
2222
describe("Update", () => {
2323
it("should return UPDATE_INSTANCE after single instance update", () => {
2424
for (const instance of editSingleEventPayloads) {
25-
const analysis = determineNextAction(instance.items || []);
25+
const analysis = inferGcalChange(instance.items || []);
2626
expect(analysis.action).toBe("UPDATE_INSTANCE");
2727
expect(analysis.modifiedInstance).toBeDefined();
2828
expect(analysis.modifiedInstance?.recurringEventId).toBeDefined();
2929
}
3030
});
3131

3232
it("should return UPDATE_SERIES after series split", () => {
33-
for (const payload of editThisAndFollowingPayloads) {
34-
const analysis = determineNextAction(payload.items || []);
33+
for (const payload of thisAndFollowingPayloads) {
34+
const analysis = inferGcalChange(payload.items || []);
3535
expect(analysis.action).toBe("UPDATE_SERIES");
3636
expect(analysis.baseEvent).toBeDefined();
3737
expect(analysis.newBaseEvent).toBeDefined();
@@ -46,7 +46,7 @@ describe("Gcal Recurring Event Payload Analysis", () => {
4646

4747
it("should return UPDATE_SERIES after series update", () => {
4848
for (const payload of editAllPayloads) {
49-
const analysis = determineNextAction(payload.items || []);
49+
const analysis = inferGcalChange(payload.items || []);
5050
expect(analysis.action).toBe("UPDATE_SERIES");
5151
expect(analysis.baseEvent).toBeDefined();
5252
expect(analysis.baseEvent?.recurrence).toBeDefined();
@@ -56,7 +56,7 @@ describe("Gcal Recurring Event Payload Analysis", () => {
5656
describe("Delete", () => {
5757
it("should return DELETE_SERIES after deleting all instances", () => {
5858
for (const payload of deleteAllPayloads) {
59-
const analysis = determineNextAction(payload.items || []);
59+
const analysis = inferGcalChange(payload.items || []);
6060
expect(analysis.action).toBe("DELETE_SERIES");
6161
expect(analysis.baseEvent).toBeUndefined();
6262
expect(analysis.modifiedInstance).toBeUndefined();
@@ -66,7 +66,7 @@ describe("Gcal Recurring Event Payload Analysis", () => {
6666

6767
it("should return DELETE_INSTANCES after deleting single instance", () => {
6868
for (const payload of deleteSingleEventPayloads) {
69-
const analysis = determineNextAction(payload.items || []);
69+
const analysis = inferGcalChange(payload.items || []);
7070
expect(analysis.action).toBe("DELETE_INSTANCES");
7171
expect(analysis.baseEvent).toBeDefined();
7272
expect(analysis.modifiedInstance).toBeDefined();
@@ -77,7 +77,7 @@ describe("Gcal Recurring Event Payload Analysis", () => {
7777

7878
it("should return DELETE_INSTANCES after deleting this and following instances", () => {
7979
for (const payload of deleteThisAndFollowingPayloads) {
80-
const analysis = determineNextAction(payload.items || []);
80+
const analysis = inferGcalChange(payload.items || []);
8181
expect(analysis.action).toBe("DELETE_INSTANCES");
8282
expect(analysis.baseEvent).toBeDefined();
8383
expect(

packages/backend/src/event/services/recurrence/parse/recur.gcal.parse.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import { error } from "@backend/common/errors/handlers/error.handler";
44
import { Summary_SeriesChange_Gcal } from "../recurrence.types";
55

66
/**
7-
* Analyzes an array of events from Google Calendar to determine the next action needed
8-
* to sync the database with Google Calendar's state.
7+
* Infer changes from Google Calendar events
98
*/
10-
export function determineNextAction(
9+
export function inferGcalChange(
1110
events: gSchema$Event[],
1211
): Summary_SeriesChange_Gcal {
1312
const parser = new GCalParser(events);

packages/backend/src/event/services/recurrence/process/compass/compass.recur.processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export class CompassRecurringEventProcessor implements RecurringEventProcessor {
283283
"Failed to update recurring event series because untilDate was empty",
284284
);
285285
}
286+
console.log("untilDate:", untilDate, typeof untilDate);
286287

287288
await this.collection.updateOne(
288289
{ _id: originalBase._id },

packages/backend/src/sync/services/notify/gcal.notification.handler.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { findCompassEventBy } from "@backend/event/queries/event.queries";
1212
import eventService from "@backend/event/services/event.service";
1313
import { RecurringEventManager } from "@backend/event/services/recurrence/manage/recurrence.manager";
1414
import { GCalRecurringEventMapper } from "@backend/event/services/recurrence/map/gcal.recur.map";
15-
import { determineNextAction } from "@backend/event/services/recurrence/parse/recur.gcal.parse";
15+
import { inferGcalChange } from "@backend/event/services/recurrence/parse/recur.gcal.parse";
1616
import { CompassRecurringEventProcessor } from "@backend/event/services/recurrence/process/compass/compass.recur.processor";
1717
import { getSync, updateSyncTokenFor } from "@backend/sync/util/sync.queries";
1818

@@ -38,6 +38,7 @@ export class GCalNotificationHandler {
3838
);
3939

4040
if (hasChanges) {
41+
console.log("++ processing changes:", changes.length);
4142
await this.processEvents(changes);
4243
return "CHANGES_PROCESSED";
4344
} else {
@@ -54,6 +55,9 @@ export class GCalNotificationHandler {
5455
syncToken,
5556
});
5657

58+
console.log("++ response after getting latest changes:");
59+
console.log(JSON.stringify(response.data, null, 2));
60+
5761
// If the nextSyncToken matches our current syncToken, we've already processed these changes
5862
if (response.data.nextSyncToken === syncToken) {
5963
logger.info(
@@ -192,15 +196,17 @@ export class GCalNotificationHandler {
192196
*/
193197
private async processRecurringEvents(recurringEvents: gSchema$Event[]) {
194198
if (recurringEvents.length > 0) {
195-
const nextAction = determineNextAction(recurringEvents);
199+
const gcalChanges = inferGcalChange(recurringEvents);
196200

197-
const mapper = new GCalRecurringEventMapper(this.userId, nextAction);
198-
const input = mapper.mapEvents();
201+
console.log("++ gcalChanges (sending these to mapper):");
202+
console.log(JSON.stringify(gcalChanges));
203+
const mapper = new GCalRecurringEventMapper(this.userId, gcalChanges);
204+
const changes = mapper.inferChanges();
199205

200206
const processor = new CompassRecurringEventProcessor(this.userId);
201207
const manager = new RecurringEventManager(processor);
202-
const result = await manager.handleAction(input);
203-
console.log("++ from processRecurringEvents:", result);
208+
const result = await manager.handleAction(changes);
209+
console.log("++ result after handling:", gcalChanges.action, ":", result);
204210
}
205211
}
206212
}

0 commit comments

Comments
 (0)