From 45ccb000c925956b19f05d74ca10a30b4fdeea3f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 19:27:30 -0700 Subject: [PATCH 1/4] feat(event): implement applyCompassPlan functionality and related tests - Introduced the `applyCompassPlan` function to handle various event operations such as creation, updating, and deletion of Compass events. - Added comprehensive unit tests for `applyCompassPlan` to ensure correct behavior across different scenarios, including standalone and recurring events. - Refactored the `CompassEventParser` and `CompassSyncProcessor` to utilize the new plan application logic, enhancing the overall event handling process. - Updated existing tests to align with the new structure and ensure robust coverage of the event synchronization features. --- .../classes/compass.event.executor.test.ts | 187 +++ .../event/classes/compass.event.executor.ts | 101 ++ .../classes/compass.event.parser.test.ts | 464 +++----- .../src/event/classes/compass.event.parser.ts | 1049 ++++++++--------- .../compass.sync.processor.all-event.test.ts | 26 +- .../__tests__/compass.sync.processor.test.ts | 139 ++- .../services/sync/compass.sync.processor.ts | 112 +- 7 files changed, 1169 insertions(+), 909 deletions(-) create mode 100644 packages/backend/src/event/classes/compass.event.executor.test.ts create mode 100644 packages/backend/src/event/classes/compass.event.executor.ts diff --git a/packages/backend/src/event/classes/compass.event.executor.test.ts b/packages/backend/src/event/classes/compass.event.executor.test.ts new file mode 100644 index 000000000..4a729b475 --- /dev/null +++ b/packages/backend/src/event/classes/compass.event.executor.test.ts @@ -0,0 +1,187 @@ +/** @jest-environment node */ +import { ObjectId } from "mongodb"; +import { + CalendarProvider, + Categories_Recurrence, + type Schema_Event, + type Schema_Event_Recur_Base, + type WithCompassId, +} from "@core/types/event.types"; +import { CompassEventRRule } from "@core/util/event/compass.event.rrule"; +import { + createMockBaseEvent, + createMockStandaloneEvent, +} from "@core/util/test/ccal.event.factory"; +import { + type CompassApplyResult, + applyCompassPlan, +} from "@backend/event/classes/compass.event.executor"; +import { type CompassOperationPlan } from "@backend/event/classes/compass.event.parser"; +import * as eventService from "@backend/event/services/event.service"; + +jest.mock("@backend/event/services/event.service", () => ({ + _createCompassEvent: jest.fn(), + _deleteInstancesAfterUntil: jest.fn(), + _deleteSeries: jest.fn(), + _deleteSingleCompassEvent: jest.fn(), + _updateCompassEvent: jest.fn(), + _updateCompassSeries: jest.fn(), +})); + +function normalizeEvent(event: Schema_Event) { + return { + ...event, + _id: new ObjectId(event._id), + }; +} + +function buildSummary() { + return { + title: "test event", + transition: [null, "STANDALONE_CONFIRMED"] as [ + null, + "STANDALONE_CONFIRMED", + ], + category: Categories_Recurrence.STANDALONE, + }; +} + +function buildTransition( + operation: CompassOperationPlan["operation"], +): CompassApplyResult["summary"] { + return { + ...buildSummary(), + operation, + }; +} + +describe("applyCompassPlan", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("creates Compass data and returns the persisted event", async () => { + const payload = createMockStandaloneEvent(); + const event = normalizeEvent(payload); + const persistedEvent = { + ...payload, + updatedAt: new Date(), + } as WithCompassId>; + + jest + .spyOn(eventService, "_createCompassEvent") + .mockResolvedValueOnce(persistedEvent); + + const plan: CompassOperationPlan = { + summary: buildSummary(), + operation: "STANDALONE_CREATED", + transitionKey: "NIL->>STANDALONE_CONFIRMED", + provider: CalendarProvider.GOOGLE, + compassMutation: "create", + googleEffect: { type: "create" }, + event, + rrule: null, + steps: [{ type: "create", event, rrule: null }], + }; + + const result = await applyCompassPlan(plan); + + expect(eventService._createCompassEvent).toHaveBeenCalledWith( + { ...event, user: event.user! }, + CalendarProvider.GOOGLE, + null, + undefined, + ); + expect(result).toEqual({ + applied: true, + summary: buildTransition("STANDALONE_CREATED"), + persistedEvent, + googleDeleteEventId: undefined, + }); + }); + + it("truncates a series before updating it", async () => { + const payload = createMockBaseEvent({ + recurrence: { rule: ["RRULE:FREQ=WEEKLY;UNTIL=20260124T170000Z"] }, + }) as Schema_Event_Recur_Base; + const event = normalizeEvent(payload) as ReturnType & + Schema_Event_Recur_Base; + const rrule = new CompassEventRRule(event as never); + const persistedEvent = { + ...payload, + updatedAt: new Date(), + } as WithCompassId>; + + jest + .spyOn(eventService, "_updateCompassSeries") + .mockResolvedValueOnce(persistedEvent); + + const until = rrule.options.until!; + const plan: CompassOperationPlan = { + summary: buildSummary(), + operation: "RECURRENCE_BASE_UPDATED", + transitionKey: "RECURRENCE_BASE->>RECURRENCE_BASE_CONFIRMED", + provider: CalendarProvider.GOOGLE, + compassMutation: "truncate_series", + googleEffect: { type: "update" }, + event, + rrule, + steps: [ + { + type: "delete_instances_after_until", + userId: event.user!, + baseId: event._id.toString(), + until, + }, + { + type: "update_series", + event, + }, + ], + }; + + const result = await applyCompassPlan(plan); + + expect(eventService._deleteInstancesAfterUntil).toHaveBeenCalledWith( + event.user!, + event._id.toString(), + until, + undefined, + ); + expect(eventService._updateCompassSeries).toHaveBeenCalledWith( + { ...event, user: event.user! }, + undefined, + ); + expect(result.persistedEvent).toBe(persistedEvent); + }); + + it("uses the deleted event gEventId when it is available", async () => { + const payload = createMockStandaloneEvent(); + const event = normalizeEvent(payload); + const deletedEvent = { + ...payload, + gEventId: "deleted-from-db", + updatedAt: new Date(), + } as WithCompassId>; + + jest + .spyOn(eventService, "_deleteSingleCompassEvent") + .mockResolvedValueOnce(deletedEvent); + + const plan: CompassOperationPlan = { + summary: buildSummary(), + operation: "STANDALONE_DELETED", + transitionKey: "STANDALONE->>STANDALONE_CANCELLED", + provider: CalendarProvider.GOOGLE, + compassMutation: "delete", + googleEffect: { type: "delete", deleteEventId: "fallback-from-plan" }, + event, + rrule: null, + steps: [{ type: "delete_single", event }], + }; + + const result = await applyCompassPlan(plan); + + expect(result.googleDeleteEventId).toBe("deleted-from-db"); + }); +}); diff --git a/packages/backend/src/event/classes/compass.event.executor.ts b/packages/backend/src/event/classes/compass.event.executor.ts new file mode 100644 index 000000000..a038f1aff --- /dev/null +++ b/packages/backend/src/event/classes/compass.event.executor.ts @@ -0,0 +1,101 @@ +import { type ClientSession } from "mongodb"; +import { type Schema_Event, type WithCompassId } from "@core/types/event.types"; +import { + type CompassOperationPlan, + type CompassPersistenceStep, +} from "@backend/event/classes/compass.event.parser"; +import { + _createCompassEvent, + _deleteInstancesAfterUntil, + _deleteSeries, + _deleteSingleCompassEvent, + _updateCompassEvent, + _updateCompassSeries, +} from "@backend/event/services/event.service"; +import { type Event_Transition } from "@backend/sync/sync.types"; + +export type CompassApplyResult = { + applied: boolean; + summary: Event_Transition; + persistedEvent?: WithCompassId>; + googleDeleteEventId?: string; +}; + +async function executeStep( + plan: CompassOperationPlan, + step: CompassPersistenceStep, + session?: ClientSession, +): Promise> | undefined> { + switch (step.type) { + case "create": + return _createCompassEvent( + { ...step.event, user: step.event.user! }, + plan.provider, + step.rrule, + session, + ); + case "update": + return _updateCompassEvent( + { ...step.event, user: step.event.user! }, + session, + ); + case "update_series": + return _updateCompassSeries( + { ...step.event, user: step.event.user! }, + session, + ); + case "delete_single": + return _deleteSingleCompassEvent( + { ...step.event, user: step.event.user! }, + session, + ); + case "delete_series": + await _deleteSeries(step.userId, step.baseId, session, step.keepBase); + + return undefined; + case "delete_instances_after_until": + await _deleteInstancesAfterUntil( + step.userId, + step.baseId, + step.until, + session, + ); + + return undefined; + } +} + +export async function applyCompassPlan( + plan: CompassOperationPlan, + session?: ClientSession, +): Promise { + const summary: Event_Transition = { + ...plan.summary, + operation: plan.operation, + }; + + let persistedEvent: WithCompassId> | undefined; + + for (const step of plan.steps) { + const result = await executeStep(plan, step, session); + + if (result) { + persistedEvent = result; + } + } + + if (plan.clearRecurrenceBeforeGoogleUpdate && persistedEvent) { + Object.assign(persistedEvent, { recurrence: null }); + } + + return { + applied: true, + summary, + persistedEvent, + googleDeleteEventId: + persistedEvent?.gEventId ?? + (plan.googleEffect.type === "delete" + ? plan.googleEffect.deleteEventId + : undefined), + }; +} diff --git a/packages/backend/src/event/classes/compass.event.parser.test.ts b/packages/backend/src/event/classes/compass.event.parser.test.ts index ae658743f..de10c9179 100644 --- a/packages/backend/src/event/classes/compass.event.parser.test.ts +++ b/packages/backend/src/event/classes/compass.event.parser.test.ts @@ -1,321 +1,171 @@ +/** @jest-environment node */ +import { ObjectId, type WithId } from "mongodb"; import { RRule } from "rrule"; -import { GCAL_MAX_RECURRENCES } from "@core/constants/core.constants"; import { - CalendarProvider, Categories_Recurrence, type CompassEvent, CompassEventStatus, - type Schema_Event_Recur_Base, - type WithCompassId, + type Schema_Event, } from "@core/types/event.types"; import { createMockBaseEvent, createMockStandaloneEvent, } from "@core/util/test/ccal.event.factory"; -import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { - cleanupCollections, - cleanupTestDb, - setupTestDb, -} from "@backend/__tests__/helpers/mock.db.setup"; -import { GenericError } from "@backend/common/errors/generic/generic.errors"; -import { CompassEventParser } from "@backend/event/classes/compass.event.parser"; -import { - testCompassEventInGcal, - testCompassEventNotInGcal, - testCompassSeries, - testCompassSeriesInGcal, - testCompassStandaloneEvent, -} from "@backend/event/classes/compass.event.parser.test.util"; - -describe.each([{ calendarProvider: CalendarProvider.GOOGLE }])( - "CompassEventParser - $calendarProvider calendar", - ({ calendarProvider }) => { - describe("Before Init", () => { - it("should be called before accessing these public members", () => { - const payload = createMockBaseEvent() as CompassEvent["payload"]; - - const event = { - payload, - status: CompassEventStatus.CONFIRMED, - } as CompassEvent; - - const parser = new CompassEventParser(event); - const developerError = GenericError.DeveloperError.description; - - expect(() => parser.category).toThrow(developerError); - expect(() => parser.isBase).toThrow(developerError); - expect(() => parser.isDbBase).toThrow(developerError); - expect(() => parser.isDbInstance).toThrow(developerError); - expect(() => parser.isDbStandalone).toThrow(developerError); - expect(() => parser.isInstance).toThrow(developerError); - expect(() => parser.isStandalone).toThrow(developerError); - expect(() => parser.rrule).toThrow(developerError); - expect(() => parser.summary).toThrow(developerError); - expect(() => parser.transition).toThrow(developerError); - }); + analyzeCompassTransition, + buildCompassTransitionContext, +} from "@backend/event/classes/compass.event.parser"; + +function toDbEvent(event: Schema_Event): WithId> { + return { + ...event, + _id: new ObjectId(event._id), + } as WithId>; +} + +describe("compass.event.parser", () => { + it("builds an explicit transition context", () => { + const payload = createMockBaseEvent(); + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + } as CompassEvent; + + const context = buildCompassTransitionContext(event, null); + + expect(context.eventCategory).toBe(Categories_Recurrence.RECURRENCE_BASE); + expect(context.dbCategory).toBeNull(); + expect(context.summary).toEqual({ + title: payload.title ?? payload._id ?? "unknown", + transition: [null, "RECURRENCE_BASE_CONFIRMED"], + category: Categories_Recurrence.RECURRENCE_BASE, }); - - describe("Init", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - it("should initialize these members after init", async () => { - const payload = createMockBaseEvent() as CompassEvent["payload"]; - - const event = { - payload, - status: CompassEventStatus.CONFIRMED, - } as CompassEvent; - - const parser = new CompassEventParser(event); - const developerError = GenericError.DeveloperError.description; - const status = event.status; - - await parser.init(); - - expect(() => parser.category).not.toThrow(developerError); - expect(() => parser.isBase).not.toThrow(developerError); - expect(() => parser.isDbBase).not.toThrow(developerError); - expect(() => parser.isDbInstance).not.toThrow(developerError); - expect(() => parser.isDbStandalone).not.toThrow(developerError); - expect(() => parser.isInstance).not.toThrow(developerError); - expect(() => parser.isStandalone).not.toThrow(developerError); - expect(() => parser.rrule).not.toThrow(developerError); - expect(() => parser.summary).not.toThrow(developerError); - expect(() => parser.transition).not.toThrow(developerError); - - expect([ - Categories_Recurrence.RECURRENCE_BASE, - Categories_Recurrence.RECURRENCE_INSTANCE, - Categories_Recurrence.STANDALONE, - ]).toContain(parser.category); - - expect([ - parser.isBase, - parser.isDbBase, - parser.isDbInstance, - parser.isDbStandalone, - parser.isInstance, - parser.isStandalone, - ]).toContain(true); - - expect(parser.rrule).toBeInstanceOf(RRule); - - expect(parser.summary).toEqual({ - title: event.payload.title ?? event.payload._id ?? "unknown", - transition: [null, `${parser.category}_${status}`], - category: parser.category, - }); - - expect(parser.getTransitionString()).toStrictEqual( - `NIL->>${parser.category}_${status}`, - ); - }); + expect(context.transitionKey).toBe("NIL->>RECURRENCE_BASE_CONFIRMED"); + expect(context.rrule).toBeInstanceOf(RRule); + }); + + it("plans a calendar create without Google work for someday events", () => { + const payload = createMockStandaloneEvent({ isSomeday: true }); + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + } as CompassEvent; + + const plan = analyzeCompassTransition(event, null); + + expect(plan.compassMutation).toBe("create"); + expect(plan.operation).toBe("STANDALONE_SOMEDAY_CREATED"); + expect(plan.googleEffect).toEqual({ type: "none" }); + expect(plan.steps).toEqual([ + expect.objectContaining({ type: "create", event: expect.any(Object) }), + ]); + }); + + it("keeps the from-category in the summary when transitioning someday to calendar", () => { + const payload = createMockStandaloneEvent({ isSomeday: false }); + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + } as CompassEvent; + const dbEvent = toDbEvent({ ...payload, isSomeday: true }); + + const plan = analyzeCompassTransition(event, dbEvent); + + expect(plan.summary.category).toBe( + Categories_Recurrence.STANDALONE_SOMEDAY, + ); + expect(plan.operation).toBe("STANDALONE_CREATED"); + expect(plan.googleEffect).toEqual({ type: "create" }); + }); + + it("uses truncate_series when only the recurrence until changes", () => { + const dbPayload = createMockBaseEvent({ + recurrence: { + rule: ["RRULE:FREQ=WEEKLY;UNTIL=20260131T170000Z"], + }, + }) as Schema_Event; + const payload = { + ...dbPayload, + recurrence: { + rule: ["RRULE:FREQ=WEEKLY;UNTIL=20260124T170000Z"], + }, + }; + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + } as CompassEvent; + + const plan = analyzeCompassTransition(event, toDbEvent(dbPayload)); + + expect(plan.compassMutation).toBe("truncate_series"); + expect(plan.steps.map(({ type }) => type)).toEqual([ + "delete_instances_after_until", + "update_series", + ]); + }); + + it("uses recreate_series when recurrence semantics other than until change", () => { + const dbPayload = createMockBaseEvent({ + recurrence: { + rule: ["RRULE:FREQ=WEEKLY;COUNT=10"], + }, + }) as Schema_Event; + const payload = { + ...dbPayload, + recurrence: { + rule: ["RRULE:FREQ=DAILY;COUNT=5"], + }, + }; + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + } as CompassEvent; + + const plan = analyzeCompassTransition(event, toDbEvent(dbPayload)); + + expect(plan.compassMutation).toBe("recreate_series"); + expect(plan.steps.map(({ type }) => type)).toEqual([ + "delete_series", + "create", + ]); + }); + + it("marks series-to-standalone updates to clear recurrence before Google sync", () => { + const dbPayload = createMockBaseEvent({ + recurrence: { + rule: ["RRULE:FREQ=WEEKLY;COUNT=10"], + }, + }) as Schema_Event; + const payload = createMockStandaloneEvent({ + _id: dbPayload._id, + user: dbPayload.user, + isSomeday: false, }); - - describe("createEvent", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - describe("Someday: ", () => { - it("should create a standalone event", async () => { - const _user = await UserDriver.createUser(); - const user = _user._id.toString(); - const status = CompassEventStatus.CONFIRMED; - const payload = createMockStandaloneEvent({ isSomeday: true, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - - await parser.createEvent(); - - const { standaloneEvent } = await testCompassStandaloneEvent(payload); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventNotInGcal(standaloneEvent); - break; - } - }); - - it("should create a base event", async () => { - const _user = await UserDriver.createUser(); - const user = _user._id.toString(); - const status = CompassEventStatus.CONFIRMED; - const payload = createMockBaseEvent({ isSomeday: true, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - - await parser.createEvent(); - - const { baseEvent } = await testCompassSeries( - payload, - GCAL_MAX_RECURRENCES, - ); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventNotInGcal(baseEvent); - break; - } - }); - }); - - describe("Calendar: ", () => { - it("should create a standalone event", async () => { - const _user = await UserDriver.createUser(); - const user = _user._id.toString(); - const status = CompassEventStatus.CONFIRMED; - const payload = createMockStandaloneEvent({ isSomeday: false, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - - await parser.createEvent(); - - const { standaloneEvent } = await testCompassStandaloneEvent(payload); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventInGcal(standaloneEvent); - break; - } - }); - - it("should create a base event", async () => { - const _user = await UserDriver.createUser(); - const user = _user._id.toString(); - const status = CompassEventStatus.CONFIRMED; - const recurrence = { rule: ["RRULE:FREQ=WEEKLY;COUNT=10"] }; - const payload = createMockBaseEvent({ recurrence, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - - await parser.createEvent(); - - const { baseEvent, instances } = await testCompassSeries(payload, 10); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassSeriesInGcal(baseEvent, instances); - break; - } - }); - }); - - describe("Transitions: ", () => { - it("should transition a someday standalone event to a standalone event", async () => { - const _user = await UserDriver.createUser(); - const user = _user._id.toString(); - const status = CompassEventStatus.CONFIRMED; - const payload = createMockStandaloneEvent({ isSomeday: true, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - - await parser.createEvent(); - - const { standaloneEvent: somedayStandaloneEvent } = - await testCompassStandaloneEvent(payload); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventNotInGcal(somedayStandaloneEvent); - break; - } - - const transitionEvent = { - payload: { - ...payload, - _id: somedayStandaloneEvent._id.toString(), - isSomeday: false, - }, - status, - } as CompassEvent; - - const transitionParser = new CompassEventParser(transitionEvent); - - await transitionParser.init(); - - await transitionParser.createEvent(); - - const { standaloneEvent } = await testCompassStandaloneEvent( - transitionEvent.payload, - ); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventInGcal(standaloneEvent); - break; - } - }); - - it("should transition a someday base event to a base event", async () => { - const _user = await UserDriver.createUser(); - const user = _user._id.toString(); - const status = CompassEventStatus.CONFIRMED; - const isSomeday = true; - const recurrence = { rule: ["RRULE:FREQ=WEEKLY;COUNT=10"] }; - const payload = createMockBaseEvent({ isSomeday, user, recurrence }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - - await parser.createEvent(); - - const { baseEvent: somedayBaseEvent } = await testCompassSeries( - payload, - 10, - ); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventNotInGcal(somedayBaseEvent); - break; - } - - const transitionEvent = { - payload: { - ...payload, - _id: somedayBaseEvent._id.toString(), - isSomeday: false, - }, - status, - } as CompassEvent; - - const transitionParser = new CompassEventParser(transitionEvent); - - await transitionParser.init(); - - await transitionParser.createEvent(); - - const { baseEvent } = await testCompassSeries( - transitionEvent.payload as WithCompassId, - 10, - ); - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: - await testCompassEventInGcal(baseEvent); - break; - } - }); - }); + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + } as CompassEvent; + + const plan = analyzeCompassTransition(event, toDbEvent(dbPayload)); + + expect(plan.operation).toBe("RECURRENCE_BASE_UPDATED"); + expect(plan.googleEffect).toEqual({ type: "update" }); + expect(plan.clearRecurrenceBeforeGoogleUpdate).toBe(true); + }); + + it("prefers the persisted gEventId for delete-oriented Google effects", () => { + const payload = createMockStandaloneEvent(); + const event = { + payload: { ...payload, gEventId: undefined }, + status: CompassEventStatus.CANCELLED, + } as CompassEvent; + const dbEvent = toDbEvent({ ...payload, gEventId: "persisted-g-event-id" }); + + const plan = analyzeCompassTransition(event, dbEvent); + + expect(plan.googleEffect).toEqual({ + type: "delete", + deleteEventId: "persisted-g-event-id", }); - }, -); + }); +}); diff --git a/packages/backend/src/event/classes/compass.event.parser.ts b/packages/backend/src/event/classes/compass.event.parser.ts index 675f668b4..3a530c948 100644 --- a/packages/backend/src/event/classes/compass.event.parser.ts +++ b/packages/backend/src/event/classes/compass.event.parser.ts @@ -1,15 +1,12 @@ -import { type ClientSession, ObjectId, type WithId } from "mongodb"; -import { Logger } from "@core/logger/winston.logger"; +import { ObjectId, type WithId } from "mongodb"; import { CalendarProvider, Categories_Recurrence, type CompassEvent, type Schema_Event, - type Schema_Event_Core, type Schema_Event_Recur_Base, type TransitionCategoriesRecurrence, type TransitionStatus, - type WithCompassId, } from "@core/types/event.types"; import { CompassEventRRule } from "@core/util/event/compass.event.rrule"; import { @@ -19,570 +16,560 @@ import { } from "@core/util/event/event.util"; import { GenericError } from "@backend/common/errors/generic/generic.errors"; import { error } from "@backend/common/errors/handlers/error.handler"; -import mongoService from "@backend/common/services/mongo.service"; -import { - _createCompassEvent, - _createGcal, - _deleteGcal, - _deleteInstancesAfterUntil, - _deleteSeries, - _deleteSingleCompassEvent, - _updateCompassEvent, - _updateCompassSeries, - _updateGcal, -} from "@backend/event/services/event.service"; import { type Event_Transition, type Operation_Sync, } from "@backend/sync/sync.types"; -export class CompassEventParser { - #logger = Logger("app.event.classes.compass.event.parser"); - #_event: CompassEvent; - #event!: WithId>; - #title!: string; - #dbEvent!: WithId> | null; - #isInstance!: boolean; - #isBase!: boolean; - #isStandalone!: boolean; - #isDbInstance!: boolean; - #isDbBase!: boolean; - #isDbStandalone!: boolean; - #rrule!: CompassEventRRule | null; - #dbRrule!: CompassEventRRule | null; - #transition!: Event_Transition["transition"]; - #summary!: Omit; - - constructor(event: CompassEvent) { - this.#_event = event; - - this.#event = { - ...event.payload, - _id: new ObjectId(event.payload._id), - } as WithId>; - } - - get isInstance(): boolean { - return this.#ensureInitInvoked(this.#isInstance); - } - - get isBase(): boolean { - return this.#ensureInitInvoked(this.#isBase); - } - - get isStandalone(): boolean { - return this.#ensureInitInvoked(this.#isStandalone); - } - - get isDbInstance(): boolean { - return this.#ensureInitInvoked(this.#isDbInstance); - } - - get isDbBase(): boolean { - return this.#ensureInitInvoked(this.#isDbBase); - } - - get isDbStandalone(): boolean { - return this.#ensureInitInvoked(this.#isDbStandalone); - } - - get rrule(): CompassEventRRule | null { - return this.#ensureInitInvoked(this.#rrule); - } - - get dbRrule(): CompassEventRRule | null { - return this.#ensureInitInvoked(this.#dbRrule); - } - - get transition(): Event_Transition["transition"] { - return this.#ensureInitInvoked(this.#transition); - } - - get category(): Categories_Recurrence { - return this.#transition?.[0] ?? this.#getCategory(); - } - - get summary(): Omit { - return this.#ensureInitInvoked(this.#summary); - } - - getTransitionString(): `${Categories_Recurrence | "NIL"}->>${TransitionCategoriesRecurrence}` { - return `${this.#transition[0] ?? "NIL"}->>${this.#transition[1]}`; - } - - /** - * init - * - * we need to fetch the compass event first to properly discriminate - * the event types since they can be ambiguous if interpreted from - * gcal sync watch updates - */ - async init(session?: ClientSession): Promise { - if (this.#dbEvent === null || !!this.#dbEvent) return; - - this.#title = this.#event.title ?? this.#event._id.toString() ?? "unknown"; - - const status: TransitionStatus = this.#_event.status; - const filter = { _id: this.#event._id, user: this.#event.user! }; - - const event = this.#event; - const cEvent = await mongoService.event.findOne(filter, { session }); - - this.#dbEvent = cEvent ?? null; - - this.#isInstance = isInstance(event); - this.#isDbInstance = cEvent ? isInstance(cEvent) : false; - - this.#isBase = isBase(event as Omit); - this.#isDbBase = cEvent ? isBase(cEvent) : false; - - this.#isStandalone = isRegularEvent(event); - this.#isDbStandalone = cEvent ? isRegularEvent(cEvent) : false; - - this.#rrule = this.#isBase - ? new CompassEventRRule( - event as WithId>, - ) - : null; - - this.#dbRrule = this.#isDbBase - ? new CompassEventRRule( - this.#dbEvent! as WithId>, - ) - : null; - - this.#transition = [ - this.#getDbCategory(), - `${this.#getCategory()}_${status}`, - ]; - - this.#summary = { - title: this.#title, - transition: this.#transition, - category: this.category, - }; - } - - async createEvent(session?: ClientSession): Promise { - this.#logger.info( - `CREATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); - - // create series in calendar providers - const { isSomeday } = this.#event; - const calendarProvider = CalendarProvider.GOOGLE; - const provider = isSomeday ? CalendarProvider.COMPASS : calendarProvider; - const userId = this.#event.user!; - - const compassEvent = ( - this.isBase ? this.rrule?.base(provider) : this.#event - )!; - - const operation: Operation_Sync = `${this.#getCategory()}_CREATED`; - const operationSummary = this.#getOperationSummary(operation); - - const cEvent = (await _createCompassEvent( - { ...compassEvent, user: userId }, - calendarProvider, - this.rrule, - session, - )) as Schema_Event_Core | null; - - if (!cEvent) return []; - - if (isSomeday) return [operationSummary]; - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - await _createGcal(userId, cEvent); - - return [operationSummary]; - } - default: - return []; +type NormalizedCompassEvent = WithId>; +type CompassTransitionKey = + `${Categories_Recurrence | "NIL"}->>${TransitionCategoriesRecurrence}`; + +export type CompassMutation = + | "create" + | "update" + | "delete" + | "update_series" + | "recreate_series" + | "truncate_series"; + +export type GoogleEffectPlan = + | { type: "none" } + | { type: "create" } + | { type: "update" } + | { type: "delete"; deleteEventId?: string }; + +export type CompassPersistenceStep = + | { + type: "create"; + event: NormalizedCompassEvent; + rrule: CompassEventRRule | null; } - } - - async updateEvent(session?: ClientSession): Promise { - this.#logger.info( - `UPDATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); - - const calendarProvider = CalendarProvider.GOOGLE; - const userId = this.#event.user!; - const { isSomeday } = this.#event; - const operation: Operation_Sync = `${this.category}_UPDATED`; - const operationSummary = this.#getOperationSummary(operation); - - const cEvent = await _updateCompassEvent( - { ...this.#event, user: userId }, - session, - ); - - if (!cEvent) return []; - - if (isSomeday) return [operationSummary]; - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - await _updateGcal(userId, cEvent as Schema_Event_Core); - - return [operationSummary]; - } - default: - return []; + | { + type: "update"; + event: NormalizedCompassEvent; } - } - - async updateSeries(session?: ClientSession): Promise { - this.#logger.info( - `UPDATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); - - const { isSomeday } = this.#event; - const calendarProvider = CalendarProvider.GOOGLE; - const provider = isSomeday ? CalendarProvider.COMPASS : calendarProvider; - const compassEvent = this.rrule!.base(provider); - const userId = compassEvent.user!; - const operation: Operation_Sync = `${this.category}_UPDATED`; - const operationSummary = this.#getOperationSummary(operation); - - const rruleDiff = this.rrule?.diffOptions(this.dbRrule!) ?? []; - const seriesSplit = rruleDiff.length > 0; - - let cEvent: WithCompassId> | null = null; - - if (seriesSplit) { - /*************************************************************************** - * Series Split Logic ****************************************************** - * ************************************************************************* - * The path to follow if the db dates are stored uniformly eg. in UTC - * is to generate the instance dates using the new rrule - * and then: - * - delete the instances that are no longer in the set - * - create missing instances that are now present in the set - * based on these dates from the db. eg. - * ************************************************************************* - * const instances = this.rrule!.instances(); - * const availableStarts = instances.map((i) => i.startDate); - * await _deleteSeries( - * userId, - * this.#event._id.toString(), - * { startDate: { $nin: availableStarts } }, - * session, - * true, - * ); - * ************************************************************************* - * We will respect only the UNTIL recurrence rule param for now - * We will cancel instances after the UNTIL date for now - * assuming the recurrence rule has an UNTIL date - */ - const diffLength = rruleDiff.length; - - const untilOnlyChanged = - rruleDiff[0]?.[0] === "until" && diffLength === 1; - - // until only changed - if (untilOnlyChanged) { - await _deleteInstancesAfterUntil( - userId, - this.#event._id.toString(), - this.rrule!.options.until!, - session, - ); - - cEvent = await _updateCompassSeries( - { ...compassEvent, user: userId }, - session, - ); - } else { - // recreate instances - await _deleteSeries(userId, this.#event._id.toString(), session, true); - - cEvent = await _createCompassEvent( - { ...compassEvent, user: userId }, - calendarProvider, - this.rrule, - session, - ); - } - } else { - cEvent = await _updateCompassSeries( - { ...compassEvent, user: userId }, - session, - ); + | { + type: "update_series"; + event: NormalizedCompassEvent; } - - if (!cEvent) return []; - - if (isSomeday) return [operationSummary]; - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - await _updateGcal(userId, cEvent as Schema_Event_Core); - - return [operationSummary]; - } - default: - return []; + | { + type: "delete_single"; + event: NormalizedCompassEvent; } - } - - async deleteEvent(session?: ClientSession): Promise { - this.#logger.info( - `DELETING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); - - const calendarProvider = CalendarProvider.GOOGLE; - const userId = this.#event.user!; - const { isSomeday } = this.#event; - const operation: Operation_Sync = `${this.category}_DELETED`; - const operationSummary = this.#getOperationSummary(operation); - - const cEvent = (await _deleteSingleCompassEvent( - { ...this.#event, user: userId }, - session, - )) as Schema_Event_Core | null; - - if (!cEvent) return []; + | { + type: "delete_series"; + userId: string; + baseId: string; + keepBase: boolean; + } + | { + type: "delete_instances_after_until"; + userId: string; + baseId: string; + until: Date; + }; - if (isSomeday) return [operationSummary]; +export type CompassTransitionContext = { + event: NormalizedCompassEvent; + dbEvent: WithId> | null; + eventCategory: Categories_Recurrence; + dbCategory: Categories_Recurrence | null; + transition: [Categories_Recurrence | null, TransitionCategoriesRecurrence]; + summary: Omit; + transitionKey: CompassTransitionKey; + isBase: boolean; + isInstance: boolean; + isStandalone: boolean; + isDbBase: boolean; + isDbInstance: boolean; + isDbStandalone: boolean; + rrule: CompassEventRRule | null; + dbRrule: CompassEventRRule | null; +}; + +export type CompassOperationPlan = { + summary: Omit; + operation: Operation_Sync; + transitionKey: CompassTransitionKey; + provider: CalendarProvider; + compassMutation: CompassMutation; + googleEffect: GoogleEffectPlan; + event: NormalizedCompassEvent; + rrule: CompassEventRRule | null; + steps: CompassPersistenceStep[]; + clearRecurrenceBeforeGoogleUpdate?: boolean; +}; + +type PlanBuilder = (context: CompassTransitionContext) => CompassOperationPlan; + +function normalizeCompassEvent(event: CompassEvent): NormalizedCompassEvent { + return { + ...event.payload, + _id: new ObjectId(event.payload._id), + } as NormalizedCompassEvent; +} - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - const ok = cEvent.gEventId - ? await _deleteGcal(userId, cEvent.gEventId) - : true; +function getCategory( + event: Pick, + { + isStandalone, + isBase, + isInstance, + }: Pick, +): Categories_Recurrence { + switch (true) { + case isStandalone && !!event.isSomeday: + return Categories_Recurrence.STANDALONE_SOMEDAY; + case isBase && !!event.isSomeday: + return Categories_Recurrence.RECURRENCE_BASE_SOMEDAY; + case isInstance && !!event.isSomeday: + return Categories_Recurrence.RECURRENCE_INSTANCE_SOMEDAY; + case isStandalone: + return Categories_Recurrence.STANDALONE; + case isBase: + return Categories_Recurrence.RECURRENCE_BASE; + case isInstance: + return Categories_Recurrence.RECURRENCE_INSTANCE; + default: + throw error( + GenericError.DeveloperError, + "could not determine event category", + ); + } +} - return ok ? [operationSummary] : []; - } - default: - return []; - } +function getDbCategory( + dbEvent: WithId> | null, + context: Pick< + CompassTransitionContext, + "isDbStandalone" | "isDbBase" | "isDbInstance" + >, +): Categories_Recurrence | null { + switch (true) { + case context.isDbStandalone && !!dbEvent?.isSomeday: + return Categories_Recurrence.STANDALONE_SOMEDAY; + case context.isDbBase && !!dbEvent?.isSomeday: + return Categories_Recurrence.RECURRENCE_BASE_SOMEDAY; + case context.isDbInstance && !!dbEvent?.isSomeday: + return Categories_Recurrence.RECURRENCE_INSTANCE_SOMEDAY; + case context.isDbStandalone: + return Categories_Recurrence.STANDALONE; + case context.isDbBase: + return Categories_Recurrence.RECURRENCE_BASE; + case context.isDbInstance: + return Categories_Recurrence.RECURRENCE_INSTANCE; + default: + return null; } +} - async standaloneToSomeday( - session?: ClientSession, - ): Promise { - this.#logger.info( - `UPDATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); +function getProvider(event: Pick): CalendarProvider { + return event.isSomeday ? CalendarProvider.COMPASS : CalendarProvider.GOOGLE; +} - const calendarProvider = CalendarProvider.GOOGLE; - const user = this.#event.user!; - const operation: Operation_Sync = `${this.category}_UPDATED`; - const operationSummary = this.#getOperationSummary(operation); +function getGoogleDeleteEventId( + context: Pick, +): string | undefined { + return context.dbEvent?.gEventId ?? context.event.gEventId; +} - const cEvent = await _createCompassEvent( - { ...this.#event, user, isSomeday: true }, - calendarProvider, - null, - session, +function getSeriesEvent( + context: Pick, + provider: CalendarProvider, +): NormalizedCompassEvent { + if (!context.rrule) { + throw error( + GenericError.DeveloperError, + "missing recurrence rule for series operation", ); + } - if (!cEvent) return []; + return context.rrule.base(provider) as NormalizedCompassEvent; +} - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - const ok = this.#event.gEventId - ? await _deleteGcal(user, this.#event.gEventId) - : true; +function createPlan( + context: CompassTransitionContext, + config: Omit, +): CompassOperationPlan { + return { + ...config, + summary: context.summary, + transitionKey: context.transitionKey, + }; +} - return ok ? [operationSummary] : []; - } - default: - return []; - } - } +function buildCreatePlan( + context: CompassTransitionContext, +): CompassOperationPlan { + const provider = getProvider(context.event); + const event = context.isBase + ? getSeriesEvent(context, provider) + : context.event; + const googleEffect = context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "create" } as const); + + return createPlan(context, { + provider, + compassMutation: "create", + googleEffect, + operation: `${context.eventCategory}_CREATED`, + event, + rrule: context.rrule, + steps: [{ type: "create", event, rrule: context.rrule }], + }); +} - async seriesToSomedaySeries( - session?: ClientSession, - ): Promise { - this.#logger.info( - `UPDATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); +function buildUpdatePlan( + context: CompassTransitionContext, +): CompassOperationPlan { + return createPlan(context, { + provider: getProvider(context.event), + compassMutation: "update", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "update" } as const), + operation: `${context.summary.category}_UPDATED`, + event: context.event, + rrule: context.rrule, + steps: [{ type: "update", event: context.event }], + }); +} - const calendarProvider = CalendarProvider.GOOGLE; - const user = this.#event.user!; - const operation: Operation_Sync = `${this.category}_UPDATED`; - const operationSummary = this.#getOperationSummary(operation); +function buildDeletePlan( + context: CompassTransitionContext, +): CompassOperationPlan { + return createPlan(context, { + provider: getProvider(context.event), + compassMutation: "delete", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ + type: "delete", + deleteEventId: getGoogleDeleteEventId(context), + } as const), + operation: `${context.summary.category}_DELETED`, + event: context.event, + rrule: context.rrule, + steps: [{ type: "delete_single", event: context.event }], + }); +} - await _deleteSeries(user, this.#event._id.toString(), session, true); +function buildStandaloneToSomedayPlan( + context: CompassTransitionContext, +): CompassOperationPlan { + const event = { + ...context.event, + isSomeday: true, + } as NormalizedCompassEvent; + + return createPlan(context, { + provider: CalendarProvider.COMPASS, + compassMutation: "create", + googleEffect: { + type: "delete", + deleteEventId: getGoogleDeleteEventId(context), + }, + operation: `${context.summary.category}_UPDATED`, + event, + rrule: null, + steps: [{ type: "create", event, rrule: null }], + }); +} - const cEvent = await _createCompassEvent( +function buildSeriesToSomedayPlan( + context: CompassTransitionContext, +): CompassOperationPlan { + const event = { + ...getSeriesEvent(context, CalendarProvider.COMPASS), + isSomeday: true, + } as NormalizedCompassEvent; + + return createPlan(context, { + provider: CalendarProvider.COMPASS, + compassMutation: "recreate_series", + googleEffect: { + type: "delete", + deleteEventId: getGoogleDeleteEventId(context), + }, + operation: `${context.summary.category}_UPDATED`, + event, + rrule: context.rrule, + steps: [ { - ...this.#event, - user, - recurrence: this.#event.recurrence, - isSomeday: true, + type: "delete_series", + userId: context.event.user!, + baseId: context.event._id.toString(), + keepBase: true, }, - calendarProvider, - this.rrule, - session, - ); + { + type: "create", + event, + rrule: context.rrule, + }, + ], + }); +} - if (!cEvent) return []; +function buildSeriesToStandalonePlan( + context: CompassTransitionContext, +): CompassOperationPlan { + return createPlan(context, { + provider: getProvider(context.event), + compassMutation: "update", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "update" } as const), + operation: `${context.summary.category}_UPDATED`, + event: context.event, + rrule: null, + steps: [ + { + type: "delete_series", + userId: context.event.user!, + baseId: context.event._id.toString(), + keepBase: true, + }, + { type: "update", event: context.event }, + ], + clearRecurrenceBeforeGoogleUpdate: !context.event.isSomeday, + }); +} - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - const ok = this.#event.gEventId - ? await _deleteGcal(user, this.#event.gEventId) - : true; +function buildStandaloneToSeriesPlan( + context: CompassTransitionContext, +): CompassOperationPlan { + return createPlan(context, { + provider: getProvider(context.event), + compassMutation: "create", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "update" } as const), + operation: `${context.summary.category}_UPDATED`, + event: context.event, + rrule: context.rrule, + steps: [{ type: "create", event: context.event, rrule: context.rrule }], + }); +} - return ok ? [operationSummary] : []; - } - default: - return []; - } +function buildUpdateSeriesPlan( + context: CompassTransitionContext, +): CompassOperationPlan { + const provider = getProvider(context.event); + const event = getSeriesEvent(context, provider); + const rruleDiff = context.rrule?.diffOptions(context.dbRrule!) ?? []; + const isSeriesSplit = rruleDiff.length > 0; + const isUntilOnlyChange = + rruleDiff[0]?.[0] === "until" && rruleDiff.length === 1; + + if (isUntilOnlyChange) { + return createPlan(context, { + provider, + compassMutation: "truncate_series", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "update" } as const), + operation: `${context.summary.category}_UPDATED`, + event, + rrule: context.rrule, + steps: [ + { + type: "delete_instances_after_until", + userId: context.event.user!, + baseId: context.event._id.toString(), + until: context.rrule!.options.until!, + }, + { + type: "update_series", + event, + }, + ], + }); } - async seriesToStandalone( - session?: ClientSession, - ): Promise { - this.#logger.info( - `UPDATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); - - const calendarProvider = CalendarProvider.GOOGLE; - const userId = this.#event.user!; - const { isSomeday } = this.#event; - const operation: Operation_Sync = `${this.category}_UPDATED`; - const operationSummary = this.#getOperationSummary(operation); - - await _deleteSeries(userId, this.#event._id.toString(), session, true); - - const cEvent = (await _updateCompassEvent( - { ...this.#event, user: userId }, - session, - )) as Schema_Event_Core | null; - - if (!cEvent) return []; - - if (isSomeday) return [operationSummary]; - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - Object.assign(cEvent, { recurrence: null }); - - await _updateGcal(userId, cEvent); - - return [operationSummary]; - } - default: - return []; - } + if (isSeriesSplit) { + return createPlan(context, { + provider, + compassMutation: "recreate_series", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "update" } as const), + operation: `${context.summary.category}_UPDATED`, + event, + rrule: context.rrule, + steps: [ + { + type: "delete_series", + userId: context.event.user!, + baseId: context.event._id.toString(), + keepBase: true, + }, + { + type: "create", + event, + rrule: context.rrule, + }, + ], + }); } - async standaloneToSeries( - session?: ClientSession, - ): Promise { - this.#logger.info( - `UPDATING ${this.getTransitionString()}: ${this.#event._id.toString()} (Compass)`, - ); - - const calendarProvider = CalendarProvider.GOOGLE; - const userId = this.#event.user!; - const { isSomeday } = this.#event; - const operation: Operation_Sync = `${this.category}_UPDATED`; - const operationSummary = this.#getOperationSummary(operation); - - const cEvent = (await _createCompassEvent( - { ...this.#event, user: userId }, - calendarProvider, - this.rrule, - session, - )) as Schema_Event_Core | null; - - if (!cEvent) return []; - - if (isSomeday) return [operationSummary]; + return createPlan(context, { + provider, + compassMutation: "update_series", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ type: "update" } as const), + operation: `${context.summary.category}_UPDATED`, + event, + rrule: context.rrule, + steps: [{ type: "update_series", event }], + }); +} - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - await _updateGcal(userId, cEvent); +function buildCancelSeriesPlan( + context: CompassTransitionContext, +): CompassOperationPlan { + return createPlan(context, { + provider: getProvider(context.event), + compassMutation: "delete", + googleEffect: context.event.isSomeday + ? ({ type: "none" } as const) + : ({ + type: "delete", + deleteEventId: getGoogleDeleteEventId(context), + } as const), + operation: `${context.summary.category}_DELETED`, + event: context.event, + rrule: context.rrule, + steps: [ + { + type: "delete_series", + userId: context.event.user!, + baseId: context.event._id.toString(), + keepBase: false, + }, + ], + }); +} - return [operationSummary]; - } - default: - return []; - } - } +const PLAN_BUILDERS: Record = { + "NIL->>STANDALONE_SOMEDAY_CONFIRMED": buildCreatePlan, + "NIL->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": buildCreatePlan, + "NIL->>STANDALONE_CONFIRMED": buildCreatePlan, + "NIL->>RECURRENCE_BASE_CONFIRMED": buildCreatePlan, + "STANDALONE_SOMEDAY->>STANDALONE_CONFIRMED": buildCreatePlan, + "RECURRENCE_BASE_SOMEDAY->>RECURRENCE_BASE_CONFIRMED": buildCreatePlan, + "RECURRENCE_INSTANCE_SOMEDAY->>STANDALONE_CONFIRMED": buildCreatePlan, + "STANDALONE_SOMEDAY->>STANDALONE_SOMEDAY_CONFIRMED": buildUpdatePlan, + "STANDALONE->>STANDALONE_CONFIRMED": buildUpdatePlan, + "RECURRENCE_INSTANCE_SOMEDAY->>RECURRENCE_INSTANCE_SOMEDAY_CONFIRMED": + buildUpdatePlan, + "RECURRENCE_INSTANCE->>RECURRENCE_INSTANCE_CONFIRMED": buildUpdatePlan, + "NIL->>STANDALONE_SOMEDAY_CANCELLED": buildDeletePlan, + "NIL->>STANDALONE_CANCELLED": buildDeletePlan, + "NIL->>RECURRENCE_INSTANCE_CANCELLED": buildDeletePlan, + "NIL->>RECURRENCE_INSTANCE_SOMEDAY_CANCELLED": buildDeletePlan, + "STANDALONE_SOMEDAY->>STANDALONE_SOMEDAY_CANCELLED": buildDeletePlan, + "STANDALONE->>STANDALONE_CANCELLED": buildDeletePlan, + "RECURRENCE_INSTANCE->>RECURRENCE_INSTANCE_CANCELLED": buildDeletePlan, + "RECURRENCE_INSTANCE_SOMEDAY->>RECURRENCE_INSTANCE_SOMEDAY_CANCELLED": + buildDeletePlan, + "STANDALONE->>STANDALONE_SOMEDAY_CONFIRMED": buildStandaloneToSomedayPlan, + "RECURRENCE_BASE->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": + buildSeriesToSomedayPlan, + "RECURRENCE_BASE_SOMEDAY->>STANDALONE_SOMEDAY_CONFIRMED": + buildSeriesToStandalonePlan, + "RECURRENCE_BASE->>STANDALONE_CONFIRMED": buildSeriesToStandalonePlan, + "STANDALONE_SOMEDAY->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": + buildStandaloneToSeriesPlan, + "STANDALONE->>RECURRENCE_BASE_CONFIRMED": buildStandaloneToSeriesPlan, + "RECURRENCE_BASE->>RECURRENCE_BASE_CONFIRMED": buildUpdateSeriesPlan, + "RECURRENCE_BASE_SOMEDAY->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": + buildUpdateSeriesPlan, + "NIL->>RECURRENCE_BASE_SOMEDAY_CANCELLED": buildCancelSeriesPlan, + "NIL->>RECURRENCE_BASE_CANCELLED": buildCancelSeriesPlan, + "RECURRENCE_BASE_SOMEDAY->>RECURRENCE_BASE_SOMEDAY_CANCELLED": + buildCancelSeriesPlan, + "RECURRENCE_BASE->>RECURRENCE_BASE_CANCELLED": buildCancelSeriesPlan, +}; + +export function buildCompassTransitionContext( + eventInput: CompassEvent, + dbEvent: WithId> | null, +): CompassTransitionContext { + const event = normalizeCompassEvent(eventInput); + const status: TransitionStatus = eventInput.status; + const isEventInstance = isInstance(event); + const isEventBase = isBase(event as Omit); + const isEventStandalone = isRegularEvent(event); + const isDatabaseInstance = dbEvent ? isInstance(dbEvent) : false; + const isDatabaseBase = dbEvent ? isBase(dbEvent) : false; + const isDatabaseStandalone = dbEvent ? isRegularEvent(dbEvent) : false; + const eventCategory = getCategory(event, { + isStandalone: isEventStandalone, + isBase: isEventBase, + isInstance: isEventInstance, + }); + const dbCategory = getDbCategory(dbEvent, { + isDbStandalone: isDatabaseStandalone, + isDbBase: isDatabaseBase, + isDbInstance: isDatabaseInstance, + }); + const rrule = isEventBase + ? new CompassEventRRule( + event as WithId>, + ) + : null; + const dbRrule = isDatabaseBase + ? new CompassEventRRule( + dbEvent as WithId>, + ) + : null; + const transition: CompassTransitionContext["transition"] = [ + dbCategory, + `${eventCategory}_${status}`, + ]; + const summaryCategory = dbCategory ?? eventCategory; + const transitionKey = + `${dbCategory ?? "NIL"}->>${transition[1]}` as CompassTransitionKey; + + return { + event, + dbEvent, + eventCategory, + dbCategory, + transition, + transitionKey, + summary: { + title: event.title ?? event._id.toString() ?? "unknown", + transition, + category: summaryCategory, + }, + isBase: isEventBase, + isInstance: isEventInstance, + isStandalone: isEventStandalone, + isDbBase: isDatabaseBase, + isDbInstance: isDatabaseInstance, + isDbStandalone: isDatabaseStandalone, + rrule, + dbRrule, + }; +} - async cancelSeries(session?: ClientSession): Promise { - this.#logger.info( - `Cancelling SERIES: ${this.#event._id.toString()} (Gcal)`, +export function analyzeCompassTransition( + event: CompassEvent, + dbEvent: WithId> | null, +): CompassOperationPlan { + const context = buildCompassTransitionContext(event, dbEvent); + const builder = PLAN_BUILDERS[context.transitionKey]; + + if (!builder) { + throw error( + GenericError.DeveloperError, + `Compass event handler failed: ${context.transitionKey}`, ); - - const calendarProvider = CalendarProvider.GOOGLE; - const userId = this.#event.user!; - const { isSomeday } = this.#event; - const operation: Operation_Sync = `${this.category}_DELETED`; - const operationSummary = this.#getOperationSummary(operation); - - await _deleteSeries(userId, this.#event._id.toString(), session); - - if (isSomeday) return [operationSummary]; - - switch (calendarProvider) { - case CalendarProvider.GOOGLE: { - const ok = this.#event.gEventId - ? await _deleteGcal(userId, this.#event.gEventId) - : true; - - return ok ? [operationSummary] : []; - } - default: - return []; - } - } - - #getCategory(): Categories_Recurrence { - switch (true) { - case this.#isStandalone && this.#event.isSomeday: - return Categories_Recurrence.STANDALONE_SOMEDAY; - case this.#isBase && this.#event.isSomeday: - return Categories_Recurrence.RECURRENCE_BASE_SOMEDAY; - case this.#isInstance && this.#event.isSomeday: - return Categories_Recurrence.RECURRENCE_INSTANCE_SOMEDAY; - case this.isStandalone: - return Categories_Recurrence.STANDALONE; - case this.isBase: - return Categories_Recurrence.RECURRENCE_BASE; - case this.isInstance: - return Categories_Recurrence.RECURRENCE_INSTANCE; - default: - throw new Error("could not determine event category"); - } } - #getDbCategory(): Categories_Recurrence | null { - switch (true) { - case this.#isDbStandalone && this.#dbEvent?.isSomeday: - return Categories_Recurrence.STANDALONE_SOMEDAY; - case this.#isDbBase && this.#dbEvent?.isSomeday: - return Categories_Recurrence.RECURRENCE_BASE_SOMEDAY; - case this.#isDbInstance && this.#dbEvent?.isSomeday: - return Categories_Recurrence.RECURRENCE_INSTANCE_SOMEDAY; - case this.#isDbStandalone: - return Categories_Recurrence.STANDALONE; - case this.#isDbBase: - return Categories_Recurrence.RECURRENCE_BASE; - case this.#isDbInstance: - return Categories_Recurrence.RECURRENCE_INSTANCE; - default: - return null; - } - } - - #getOperationSummary(operation: Operation_Sync): Event_Transition { - return this.#ensureInitInvoked({ ...this.summary, operation }); - } - - #ensureInitInvoked(value: T) { - if (this.#dbEvent === undefined) { - throw error(GenericError.DeveloperError, "did you call `init` yet"); - } - - return value; - } + return builder(context); } diff --git a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.all-event.test.ts b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.all-event.test.ts index 97e292a94..159c39f59 100644 --- a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.all-event.test.ts +++ b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.all-event.test.ts @@ -5,7 +5,6 @@ import { CalendarProvider, Categories_Recurrence, type CompassAllEvents, - type CompassEvent, CompassEventStatus, type CompassThisEvent, RecurringEventUpdateScope, @@ -20,7 +19,6 @@ import { setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; import mongoService from "@backend/common/services/mongo.service"; -import { CompassEventParser } from "@backend/event/classes/compass.event.parser"; import { testCompassSeries, testCompassSeriesInGcal, @@ -1355,12 +1353,14 @@ describe.each([{ calendarProvider: CalendarProvider.GOOGLE }])( const status = CompassEventStatus.CONFIRMED; const recurrence = { rule: ["RRULE:FREQ=WEEKLY;COUNT=10"] }; const payload = createMockBaseEvent({ recurrence, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - await parser.init(); - - await parser.createEvent(); + await CompassSyncProcessor.processEvents([ + { + payload: payload as CompassThisEvent["payload"], + applyTo: RecurringEventUpdateScope.THIS_EVENT, + status, + }, + ]); const { baseEvent, instances } = await testCompassSeries( payload, @@ -1654,12 +1654,14 @@ describe.each([{ calendarProvider: CalendarProvider.GOOGLE }])( const status = CompassEventStatus.CONFIRMED; const recurrence = { rule: ["RRULE:FREQ=WEEKLY;COUNT=10"] }; const payload = createMockBaseEvent({ recurrence, user }); - const event = { payload, status } as CompassEvent; - const parser = new CompassEventParser(event); - - await parser.init(); - await parser.createEvent(); + await CompassSyncProcessor.processEvents([ + { + payload: payload as CompassThisEvent["payload"], + applyTo: RecurringEventUpdateScope.THIS_EVENT, + status, + }, + ]); const { baseEvent, instances } = await testCompassSeries(payload, 10); diff --git a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts index 07a9bab19..ae7a89670 100644 --- a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts +++ b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts @@ -1,9 +1,11 @@ +/** @jest-environment node */ import { faker } from "@faker-js/faker"; import { EVENT_CHANGED, SOMEDAY_EVENT_CHANGED, } from "@core/constants/websocket.constants"; import { + CalendarProvider, Categories_Recurrence, type CompassEvent, CompassEventStatus, @@ -13,6 +15,11 @@ import { createMockBaseEvent, createMockStandaloneEvent, } from "@core/util/test/ccal.event.factory"; +import mongoService from "@backend/common/services/mongo.service"; +import { type CompassApplyResult } from "@backend/event/classes/compass.event.executor"; +import * as compassExecutor from "@backend/event/classes/compass.event.executor"; +import * as compassParser from "@backend/event/classes/compass.event.parser"; +import * as eventService from "@backend/event/services/event.service"; import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { CompassSyncProcessor } from "@backend/sync/services/sync/compass.sync.processor"; import { type Event_Transition } from "@backend/sync/sync.types"; @@ -91,22 +98,17 @@ describe("CompassSyncProcessor.notifyClients", () => { const userA = faker.database.mongodbObjectId(); const userB = faker.database.mongodbObjectId(); - const events: CompassEvent[] = [ + const events = [ { applyTo, status, - payload: createMockBaseEvent({ - user: userA, - }) as CompassEvent["payload"], - }, + payload: createMockBaseEvent({ user: userA }), + } as CompassEvent, { applyTo, status, - payload: createMockStandaloneEvent({ - isSomeday: true, - user: userB, - }) as CompassEvent["payload"], - }, + payload: createMockStandaloneEvent({ isSomeday: true, user: userB }), + } as CompassEvent, ]; const summary: Event_Transition[] = [ @@ -130,3 +132,120 @@ describe("CompassSyncProcessor.notifyClients", () => { expect(somedaySpy).toHaveBeenCalledWith(userB); }); }); + +describe("CompassSyncProcessor.handleCompassChange", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it("loads db state, analyzes, applies, and emits the summary", async () => { + const payload = createMockStandaloneEvent(); + const event = { + payload, + status: CompassEventStatus.CONFIRMED, + applyTo: RecurringEventUpdateScope.THIS_EVENT, + } as CompassEvent; + const dbEvent = null; + const plan: compassParser.CompassOperationPlan = { + summary: { + title: payload.title!, + transition: [null, "STANDALONE_CONFIRMED"], + category: Categories_Recurrence.STANDALONE, + }, + operation: "STANDALONE_CREATED", + transitionKey: "NIL->>STANDALONE_CONFIRMED", + provider: CalendarProvider.GOOGLE, + compassMutation: "create", + googleEffect: { type: "none" }, + event: payload as never, + rrule: null, + steps: [], + }; + const applyResult: CompassApplyResult = { + applied: true, + summary: { + ...plan.summary, + operation: plan.operation, + }, + }; + + const findOneSpy = jest.fn().mockResolvedValueOnce(dbEvent); + jest + .spyOn(mongoService, "event", "get") + .mockReturnValue({ findOne: findOneSpy } as never); + const analyzeSpy = jest + .spyOn(compassParser, "analyzeCompassTransition") + .mockReturnValueOnce(plan as never); + const applySpy = jest + .spyOn(compassExecutor, "applyCompassPlan") + .mockResolvedValueOnce(applyResult); + + await expect( + CompassSyncProcessor["handleCompassChange"](event), + ).resolves.toEqual([applyResult.summary]); + + expect(findOneSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: payload.user }), + { session: undefined }, + ); + expect(analyzeSpy).toHaveBeenCalledWith(event, dbEvent); + expect(applySpy).toHaveBeenCalledWith(plan, undefined); + expect(findOneSpy.mock.invocationCallOrder[0]).toBeLessThan( + analyzeSpy.mock.invocationCallOrder[0]!, + ); + expect(analyzeSpy.mock.invocationCallOrder[0]).toBeLessThan( + applySpy.mock.invocationCallOrder[0]!, + ); + }); + + it("deletes Google events using the persisted db gEventId fallback", async () => { + const payload = createMockStandaloneEvent({ gEventId: undefined }); + const event = { + payload, + status: CompassEventStatus.CANCELLED, + applyTo: RecurringEventUpdateScope.THIS_EVENT, + } as CompassEvent; + const dbEvent = { + ...payload, + gEventId: "persisted-google-id", + }; + const deleteSpy = jest + .spyOn(eventService, "_deleteGcal") + .mockResolvedValueOnce(true); + + const findOneSpy = jest.fn().mockResolvedValueOnce(dbEvent); + jest + .spyOn(mongoService, "event", "get") + .mockReturnValue({ findOne: findOneSpy } as never); + jest.spyOn(compassExecutor, "applyCompassPlan").mockImplementationOnce( + async (plan) => + ({ + applied: true, + summary: { + ...plan.summary, + operation: plan.operation, + }, + googleDeleteEventId: + plan.googleEffect.type === "delete" + ? plan.googleEffect.deleteEventId + : undefined, + }) as CompassApplyResult, + ); + + await expect( + CompassSyncProcessor["handleCompassChange"](event), + ).resolves.toEqual([ + { + title: payload.title!, + transition: [Categories_Recurrence.STANDALONE, "STANDALONE_CANCELLED"], + category: Categories_Recurrence.STANDALONE, + operation: "STANDALONE_DELETED", + }, + ]); + + expect(deleteSpy).toHaveBeenCalledWith( + payload.user!, + "persisted-google-id", + ); + }); +}); diff --git a/packages/backend/src/sync/services/sync/compass.sync.processor.ts b/packages/backend/src/sync/services/sync/compass.sync.processor.ts index 759ef606f..72e3c9db9 100644 --- a/packages/backend/src/sync/services/sync/compass.sync.processor.ts +++ b/packages/backend/src/sync/services/sync/compass.sync.processor.ts @@ -1,15 +1,27 @@ -import { type ClientSession } from "mongodb"; +import { type ClientSession, ObjectId } from "mongodb"; import { EVENT_CHANGED, SOMEDAY_EVENT_CHANGED, } from "@core/constants/websocket.constants"; import { Logger } from "@core/logger/winston.logger"; -import { type CompassEvent } from "@core/types/event.types"; +import { + type CompassEvent, + type Schema_Event_Core, +} from "@core/types/event.types"; import { GenericError } from "@backend/common/errors/generic/generic.errors"; import { error } from "@backend/common/errors/handlers/error.handler"; import mongoService from "@backend/common/services/mongo.service"; +import { applyCompassPlan } from "@backend/event/classes/compass.event.executor"; import { CompassEventFactory } from "@backend/event/classes/compass.event.generator"; -import { CompassEventParser } from "@backend/event/classes/compass.event.parser"; +import { + type CompassOperationPlan, + analyzeCompassTransition, +} from "@backend/event/classes/compass.event.parser"; +import { + _createGcal, + _deleteGcal, + _updateGcal, +} from "@backend/event/services/event.service"; import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { type Event_Transition } from "@backend/sync/sync.types"; @@ -114,59 +126,61 @@ export class CompassSyncProcessor { session?: ClientSession, ): Promise { const eventId = event.payload._id; - const parser = new CompassEventParser(event); + const dbEvent = await mongoService.event.findOne( + { _id: new ObjectId(eventId), user: event.payload.user! }, + { session }, + ); + const plan = analyzeCompassTransition(event, dbEvent); + const transition = plan.transitionKey; - await parser.init(session); + logger.info(`Handle Compass event(${eventId}): ${transition}`); - const transition = parser.getTransitionString(); + const applyResult = await applyCompassPlan(plan, session); - logger.info(`Handle Compass event(${eventId}): ${transition}`); + if (!applyResult.applied) return []; + + const didExecuteGoogleEffect = + await CompassSyncProcessor.executeGoogleEffect(plan, applyResult); + + return didExecuteGoogleEffect ? [applyResult.summary] : []; + } + + private static async executeGoogleEffect( + plan: CompassOperationPlan, + { + googleDeleteEventId, + persistedEvent, + }: Awaited>, + ): Promise { + switch (plan.googleEffect.type) { + case "none": + return true; + case "create": + if (!persistedEvent) return false; + + await _createGcal( + persistedEvent.user!, + persistedEvent as Schema_Event_Core, + ); + + return true; + case "update": + if (!persistedEvent) return false; + + await _updateGcal( + persistedEvent.user!, + persistedEvent as Schema_Event_Core, + ); - switch (transition) { - case "NIL->>STANDALONE_SOMEDAY_CONFIRMED": - case "NIL->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": - case "NIL->>STANDALONE_CONFIRMED": - case "NIL->>RECURRENCE_BASE_CONFIRMED": - case "STANDALONE_SOMEDAY->>STANDALONE_CONFIRMED": - case "RECURRENCE_BASE_SOMEDAY->>RECURRENCE_BASE_CONFIRMED": - case "RECURRENCE_INSTANCE_SOMEDAY->>STANDALONE_CONFIRMED": - return parser.createEvent(session); - case "STANDALONE_SOMEDAY->>STANDALONE_SOMEDAY_CONFIRMED": - case "STANDALONE->>STANDALONE_CONFIRMED": - case "RECURRENCE_INSTANCE_SOMEDAY->>RECURRENCE_INSTANCE_SOMEDAY_CONFIRMED": - case "RECURRENCE_INSTANCE->>RECURRENCE_INSTANCE_CONFIRMED": - return parser.updateEvent(session); - case "NIL->>STANDALONE_SOMEDAY_CANCELLED": - case "NIL->>STANDALONE_CANCELLED": - case "NIL->>RECURRENCE_INSTANCE_CANCELLED": - case "NIL->>RECURRENCE_INSTANCE_SOMEDAY_CANCELLED": - case "STANDALONE_SOMEDAY->>STANDALONE_SOMEDAY_CANCELLED": - case "STANDALONE->>STANDALONE_CANCELLED": - case "RECURRENCE_INSTANCE->>RECURRENCE_INSTANCE_CANCELLED": - case "RECURRENCE_INSTANCE_SOMEDAY->>RECURRENCE_INSTANCE_SOMEDAY_CANCELLED": - return parser.deleteEvent(session); - case "STANDALONE->>STANDALONE_SOMEDAY_CONFIRMED": - return parser.standaloneToSomeday(session); - case "RECURRENCE_BASE->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": - return parser.seriesToSomedaySeries(session); - case "RECURRENCE_BASE_SOMEDAY->>STANDALONE_SOMEDAY_CONFIRMED": - case "RECURRENCE_BASE->>STANDALONE_CONFIRMED": - return parser.seriesToStandalone(session); - case "STANDALONE_SOMEDAY->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": - case "STANDALONE->>RECURRENCE_BASE_CONFIRMED": - return parser.standaloneToSeries(session); - case "RECURRENCE_BASE->>RECURRENCE_BASE_CONFIRMED": - case "RECURRENCE_BASE_SOMEDAY->>RECURRENCE_BASE_SOMEDAY_CONFIRMED": - return parser.updateSeries(session); - case "NIL->>RECURRENCE_BASE_SOMEDAY_CANCELLED": - case "NIL->>RECURRENCE_BASE_CANCELLED": - case "RECURRENCE_BASE_SOMEDAY->>RECURRENCE_BASE_SOMEDAY_CANCELLED": - case "RECURRENCE_BASE->>RECURRENCE_BASE_CANCELLED": - return parser.cancelSeries(session); + return true; + case "delete": + return googleDeleteEventId + ? _deleteGcal(plan.event.user!, googleDeleteEventId) + : true; default: throw error( GenericError.DeveloperError, - `Compass event handler failed: ${transition}`, + `Unknown Google effect for Compass transition: ${plan.transitionKey}`, ); } } From 7bbf6772773d6ba2aa36e031c450bf13cfd29e0f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 19:33:03 -0700 Subject: [PATCH 2/4] docs: enhance event handling documentation and introduce recurrence handling guide - Updated the backend request flow documentation to clarify the processing steps in the `CompassSyncProcessor`. - Revised common change recipes to include new references for recurrence handling and related files. - Added a new document detailing the recurrence handling lifecycle, including structural models, categories, and update scopes for recurring events. - Improved the Google sync and websocket flow documentation to reflect changes in event processing and side effects. - Ensured all relevant files are documented for better understanding of event and task domain models. --- docs/backend-request-flow.md | 7 +- docs/common-change-recipes.md | 11 ++- docs/event-and-task-domain-model.md | 3 + docs/google-sync-and-websocket-flow.md | 8 +- docs/recurrence-handling.md | 122 +++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 docs/recurrence-handling.md diff --git a/docs/backend-request-flow.md b/docs/backend-request-flow.md index 2efdf3631..c2a6561be 100644 --- a/docs/backend-request-flow.md +++ b/docs/backend-request-flow.md @@ -95,7 +95,12 @@ For `POST /api/event`: 4. controller normalizes single vs array payloads 5. controller forwards the change set to `CompassSyncProcessor` 6. controller returns a status-only payload (`{ statusCode: 204 }`) through `res.promise(...)` -7. processor persists and syncs changes, then notifies clients +7. processor: + - loads current Compass DB state + - analyzes the transition into a persistence plan + - applies Compass DB mutations + - executes any Google side effect + - notifies clients after commit `PUT /api/event/:id` and `DELETE /api/event/:id` follow the same write pattern: diff --git a/docs/common-change-recipes.md b/docs/common-change-recipes.md index 8acfda05e..9832830d2 100644 --- a/docs/common-change-recipes.md +++ b/docs/common-change-recipes.md @@ -24,10 +24,13 @@ Rule: never treat event shape as web-only unless the field is strictly presentat ## Change Recurring Event Behavior 1. Read `packages/core/src/types/event.types.ts`. -2. Read `packages/backend/src/sync/services/sync/compass.sync.processor.ts`. -3. Read `packages/backend/src/event/classes/compass.event.parser.ts`. -4. Update the relevant transition path. -5. Add focused tests for the exact recurrence transition you changed. +2. Read `docs/recurrence-handling.md`. +3. Read `packages/backend/src/event/classes/compass.event.generator.ts`. +4. Read `packages/backend/src/event/classes/compass.event.parser.ts`. +5. Read `packages/backend/src/event/classes/compass.event.executor.ts`. +6. Read `packages/backend/src/sync/services/sync/compass.sync.processor.ts`. +7. Update the planner, executor, or scope-expansion path that actually owns the behavior. +8. Add focused tests for the exact recurrence transition you changed. Do not edit recurring behavior from one layer only. diff --git a/docs/event-and-task-domain-model.md b/docs/event-and-task-domain-model.md index ba9cb5fb3..629da06f4 100644 --- a/docs/event-and-task-domain-model.md +++ b/docs/event-and-task-domain-model.md @@ -45,6 +45,8 @@ These are UI-facing categories, not storage categories. Many sync and parser decisions key off transitions between these states. +For the full recurring-event lifecycle, see [recurrence-handling.md](./recurrence-handling.md). + ## Update Scopes Recurring edits use `RecurringEventUpdateScope`: @@ -72,6 +74,7 @@ Primary code: - `packages/backend/src/event/services/event.service.ts` - `packages/backend/src/event/classes/compass.event.parser.ts` +- `packages/backend/src/event/classes/compass.event.executor.ts` - `packages/backend/src/event/classes/compass.event.generator.ts` ## Someday Semantics diff --git a/docs/google-sync-and-websocket-flow.md b/docs/google-sync-and-websocket-flow.md index 41e66de2c..4a62920e6 100644 --- a/docs/google-sync-and-websocket-flow.md +++ b/docs/google-sync-and-websocket-flow.md @@ -29,7 +29,11 @@ High-level path: 3. The selected repository writes locally or remotely. 4. Remote event writes hit backend event routes. 5. `EventController` packages the change as a `CompassEvent`. -6. `CompassSyncProcessor.processEvents()` parses the event transition and applies persistence/sync logic. +6. `CompassSyncProcessor.processEvents()`: + - loads the current DB event + - analyzes the transition into a `CompassOperationPlan` + - applies Compass persistence steps + - executes any Google side effect implied by the plan 7. After commit, the backend emits websocket notifications based on whether the change affected normal or someday events. Primary files: @@ -37,6 +41,8 @@ Primary files: - `packages/web/src/ducks/events/sagas/event.sagas.ts` - `packages/web/src/common/repositories/event` - `packages/backend/src/event/controllers/event.controller.ts` +- `packages/backend/src/event/classes/compass.event.parser.ts` +- `packages/backend/src/event/classes/compass.event.executor.ts` - `packages/backend/src/sync/services/sync/compass.sync.processor.ts` ## Inbound Flow: Google Notifies Compass About Changes diff --git a/docs/recurrence-handling.md b/docs/recurrence-handling.md new file mode 100644 index 000000000..764c08899 --- /dev/null +++ b/docs/recurrence-handling.md @@ -0,0 +1,122 @@ +# Recurrence Handling + +This document explains how Compass models recurring events, how recurring edits are expanded, and how Compass and Google stay in sync after a recurrence change. + +## Structural Model + +Compass stores recurring events as: + +- one base event with `recurrence.rule` +- zero or more generated instances with `recurrence.eventId` + +The base event owns the recurrence rule. Instances do not carry their own independent rule in storage; they point back to the base. When the backend returns an instance through normal event reads, it rehydrates recurrence information from the base. + +Primary files: + +- `packages/core/src/types/event.types.ts` +- `packages/core/src/util/event/compass.event.rrule.ts` +- `packages/backend/src/event/services/event.service.ts` + +## Recurrence Categories + +Compass sync logic classifies event shape using `Categories_Recurrence`: + +- `STANDALONE` +- `RECURRENCE_BASE` +- `RECURRENCE_INSTANCE` +- `STANDALONE_SOMEDAY` +- `RECURRENCE_BASE_SOMEDAY` +- `RECURRENCE_INSTANCE_SOMEDAY` + +The Compass sync path treats recurrence handling as a transition problem: + +1. build a transition context from the incoming Compass payload plus the current DB event +2. analyze that transition into a plain `CompassOperationPlan` +3. apply Compass persistence steps from the plan +4. execute Google side effects separately if the plan calls for them + +Primary files: + +- `packages/backend/src/event/classes/compass.event.parser.ts` +- `packages/backend/src/event/classes/compass.event.executor.ts` +- `packages/backend/src/sync/services/sync/compass.sync.processor.ts` + +## Update Scopes + +Recurring edits start with `RecurringEventUpdateScope`: + +- `This Event` +- `This and Following Events` +- `All Events` + +`CompassEventFactory` expands those user-facing scopes into one or more normalized `CompassEvent` payloads before sync processing runs. + +Examples: + +- `This Event` on a recurring instance becomes a single instance update/delete +- `This and Following Events` splits the existing series into: + - a truncated old series + - a new series starting at the edited instance +- `All Events` resolves to a base-series mutation + +Primary file: + +- `packages/backend/src/event/classes/compass.event.generator.ts` + +## How Series Mutations Work + +The recurrence planner distinguishes several Compass mutation shapes: + +- `create`: create a standalone event or a new series +- `update`: update one stored event +- `delete`: delete one stored event or one full series +- `update_series`: update base/instance shared fields without rebuilding the series +- `truncate_series`: delete instances after a new `UNTIL` date, then update the base series +- `recreate_series`: delete generated instances, then recreate the series from the new rule + +Current split rule: + +- if only the RRULE `UNTIL` changed, use `truncate_series` +- if other recurrence options changed, use `recreate_series` +- if no recurrence split is needed, use `update_series` + +This keeps the recurrence interpretation in the planner and the DB mutations in the executor. + +## Someday And Provider Semantics + +`isSomeday` changes who is treated as the provider of record: + +- normal events usually persist with Google provider data and may mirror to Google +- someday events persist as Compass-owned events and skip Google side effects + +Transitions between someday and non-someday states are still analyzed as recurrence transitions. The plan decides whether Google should receive `create`, `update`, `delete`, or `none`. + +## Google Sync Boundary + +The recurrence planner does not call Google directly. + +Instead: + +- `analyzeCompassTransition(...)` describes the implied Google effect +- `applyCompassPlan(...)` performs only Compass DB mutations +- `CompassSyncProcessor` executes Google create/update/delete after Compass persistence succeeds + +Delete-oriented Google effects should prefer the persisted DB `gEventId` when available, then fall back to the incoming payload `gEventId`. + +## What To Verify When Changing Recurrence Logic + +- transition classification for base, instance, standalone, and someday shapes +- `RecurringEventUpdateScope` expansion in `CompassEventFactory` +- RRULE split behavior for: + - no split + - `UNTIL`-only truncation + - full series recreation +- Google side effects for someday/non-someday transitions +- websocket notifications for calendar vs someday changes + +Good test anchors: + +- `packages/backend/src/event/classes/compass.event.parser.test.ts` +- `packages/backend/src/event/classes/compass.event.executor.test.ts` +- `packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.all-event.test.ts` +- `packages/backend/src/sync/services/sync/__tests__/compass-sync-processor-this-event/*.test.ts` From 58698316230c960164f3704d44a683adfb66e70e Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 19:37:04 -0700 Subject: [PATCH 3/4] refactor: standardize compass mutation strings to uppercase - Updated the Compass mutation strings in the codebase to use uppercase naming conventions for consistency. - Adjusted related tests and documentation to reflect these changes, ensuring uniformity across the application. - Enhanced clarity and maintainability of the event handling logic by adhering to a unified naming standard. --- docs/recurrence-handling.md | 18 +++++----- .../classes/compass.event.executor.test.ts | 6 ++-- .../classes/compass.event.parser.test.ts | 6 ++-- .../src/event/classes/compass.event.parser.ts | 34 +++++++++---------- .../__tests__/compass.sync.processor.test.ts | 2 +- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/recurrence-handling.md b/docs/recurrence-handling.md index 764c08899..ecca2657f 100644 --- a/docs/recurrence-handling.md +++ b/docs/recurrence-handling.md @@ -67,18 +67,18 @@ Primary file: The recurrence planner distinguishes several Compass mutation shapes: -- `create`: create a standalone event or a new series -- `update`: update one stored event -- `delete`: delete one stored event or one full series -- `update_series`: update base/instance shared fields without rebuilding the series -- `truncate_series`: delete instances after a new `UNTIL` date, then update the base series -- `recreate_series`: delete generated instances, then recreate the series from the new rule +- `CREATE`: create a standalone event or a new series +- `UPDATE`: update one stored event +- `DELETE`: delete one stored event or one full series +- `UPDATE_SERIES`: update base/instance shared fields without rebuilding the series +- `TRUNCATE_SERIES`: delete instances after a new `UNTIL` date, then update the base series +- `RECREATE_SERIES`: delete generated instances, then recreate the series from the new rule Current split rule: -- if only the RRULE `UNTIL` changed, use `truncate_series` -- if other recurrence options changed, use `recreate_series` -- if no recurrence split is needed, use `update_series` +- if only the RRULE `UNTIL` changed, use `TRUNCATE_SERIES` +- if other recurrence options changed, use `RECREATE_SERIES` +- if no recurrence split is needed, use `UPDATE_SERIES` This keeps the recurrence interpretation in the planner and the DB mutations in the executor. diff --git a/packages/backend/src/event/classes/compass.event.executor.test.ts b/packages/backend/src/event/classes/compass.event.executor.test.ts index 4a729b475..b6f08e2c7 100644 --- a/packages/backend/src/event/classes/compass.event.executor.test.ts +++ b/packages/backend/src/event/classes/compass.event.executor.test.ts @@ -77,7 +77,7 @@ describe("applyCompassPlan", () => { operation: "STANDALONE_CREATED", transitionKey: "NIL->>STANDALONE_CONFIRMED", provider: CalendarProvider.GOOGLE, - compassMutation: "create", + compassMutation: "CREATE", googleEffect: { type: "create" }, event, rrule: null, @@ -122,7 +122,7 @@ describe("applyCompassPlan", () => { operation: "RECURRENCE_BASE_UPDATED", transitionKey: "RECURRENCE_BASE->>RECURRENCE_BASE_CONFIRMED", provider: CalendarProvider.GOOGLE, - compassMutation: "truncate_series", + compassMutation: "TRUNCATE_SERIES", googleEffect: { type: "update" }, event, rrule, @@ -173,7 +173,7 @@ describe("applyCompassPlan", () => { operation: "STANDALONE_DELETED", transitionKey: "STANDALONE->>STANDALONE_CANCELLED", provider: CalendarProvider.GOOGLE, - compassMutation: "delete", + compassMutation: "DELETE", googleEffect: { type: "delete", deleteEventId: "fallback-from-plan" }, event, rrule: null, diff --git a/packages/backend/src/event/classes/compass.event.parser.test.ts b/packages/backend/src/event/classes/compass.event.parser.test.ts index de10c9179..74d747ee9 100644 --- a/packages/backend/src/event/classes/compass.event.parser.test.ts +++ b/packages/backend/src/event/classes/compass.event.parser.test.ts @@ -53,7 +53,7 @@ describe("compass.event.parser", () => { const plan = analyzeCompassTransition(event, null); - expect(plan.compassMutation).toBe("create"); + expect(plan.compassMutation).toBe("CREATE"); expect(plan.operation).toBe("STANDALONE_SOMEDAY_CREATED"); expect(plan.googleEffect).toEqual({ type: "none" }); expect(plan.steps).toEqual([ @@ -97,7 +97,7 @@ describe("compass.event.parser", () => { const plan = analyzeCompassTransition(event, toDbEvent(dbPayload)); - expect(plan.compassMutation).toBe("truncate_series"); + expect(plan.compassMutation).toBe("TRUNCATE_SERIES"); expect(plan.steps.map(({ type }) => type)).toEqual([ "delete_instances_after_until", "update_series", @@ -123,7 +123,7 @@ describe("compass.event.parser", () => { const plan = analyzeCompassTransition(event, toDbEvent(dbPayload)); - expect(plan.compassMutation).toBe("recreate_series"); + expect(plan.compassMutation).toBe("RECREATE_SERIES"); expect(plan.steps.map(({ type }) => type)).toEqual([ "delete_series", "create", diff --git a/packages/backend/src/event/classes/compass.event.parser.ts b/packages/backend/src/event/classes/compass.event.parser.ts index 3a530c948..8e6c25e72 100644 --- a/packages/backend/src/event/classes/compass.event.parser.ts +++ b/packages/backend/src/event/classes/compass.event.parser.ts @@ -26,12 +26,12 @@ type CompassTransitionKey = `${Categories_Recurrence | "NIL"}->>${TransitionCategoriesRecurrence}`; export type CompassMutation = - | "create" - | "update" - | "delete" - | "update_series" - | "recreate_series" - | "truncate_series"; + | "CREATE" + | "UPDATE" + | "DELETE" + | "UPDATE_SERIES" + | "RECREATE_SERIES" + | "TRUNCATE_SERIES"; export type GoogleEffectPlan = | { type: "none" } @@ -212,7 +212,7 @@ function buildCreatePlan( return createPlan(context, { provider, - compassMutation: "create", + compassMutation: "CREATE", googleEffect, operation: `${context.eventCategory}_CREATED`, event, @@ -226,7 +226,7 @@ function buildUpdatePlan( ): CompassOperationPlan { return createPlan(context, { provider: getProvider(context.event), - compassMutation: "update", + compassMutation: "UPDATE", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ type: "update" } as const), @@ -242,7 +242,7 @@ function buildDeletePlan( ): CompassOperationPlan { return createPlan(context, { provider: getProvider(context.event), - compassMutation: "delete", + compassMutation: "DELETE", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ @@ -266,7 +266,7 @@ function buildStandaloneToSomedayPlan( return createPlan(context, { provider: CalendarProvider.COMPASS, - compassMutation: "create", + compassMutation: "CREATE", googleEffect: { type: "delete", deleteEventId: getGoogleDeleteEventId(context), @@ -288,7 +288,7 @@ function buildSeriesToSomedayPlan( return createPlan(context, { provider: CalendarProvider.COMPASS, - compassMutation: "recreate_series", + compassMutation: "RECREATE_SERIES", googleEffect: { type: "delete", deleteEventId: getGoogleDeleteEventId(context), @@ -317,7 +317,7 @@ function buildSeriesToStandalonePlan( ): CompassOperationPlan { return createPlan(context, { provider: getProvider(context.event), - compassMutation: "update", + compassMutation: "UPDATE", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ type: "update" } as const), @@ -342,7 +342,7 @@ function buildStandaloneToSeriesPlan( ): CompassOperationPlan { return createPlan(context, { provider: getProvider(context.event), - compassMutation: "create", + compassMutation: "CREATE", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ type: "update" } as const), @@ -366,7 +366,7 @@ function buildUpdateSeriesPlan( if (isUntilOnlyChange) { return createPlan(context, { provider, - compassMutation: "truncate_series", + compassMutation: "TRUNCATE_SERIES", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ type: "update" } as const), @@ -391,7 +391,7 @@ function buildUpdateSeriesPlan( if (isSeriesSplit) { return createPlan(context, { provider, - compassMutation: "recreate_series", + compassMutation: "RECREATE_SERIES", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ type: "update" } as const), @@ -416,7 +416,7 @@ function buildUpdateSeriesPlan( return createPlan(context, { provider, - compassMutation: "update_series", + compassMutation: "UPDATE_SERIES", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ type: "update" } as const), @@ -432,7 +432,7 @@ function buildCancelSeriesPlan( ): CompassOperationPlan { return createPlan(context, { provider: getProvider(context.event), - compassMutation: "delete", + compassMutation: "DELETE", googleEffect: context.event.isSomeday ? ({ type: "none" } as const) : ({ diff --git a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts index ae7a89670..a72f19893 100644 --- a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts +++ b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts @@ -155,7 +155,7 @@ describe("CompassSyncProcessor.handleCompassChange", () => { operation: "STANDALONE_CREATED", transitionKey: "NIL->>STANDALONE_CONFIRMED", provider: CalendarProvider.GOOGLE, - compassMutation: "create", + compassMutation: "CREATE", googleEffect: { type: "none" }, event: payload as never, rrule: null, From b3725f27e1098b20786e3f8b4fd8f03657a30d32 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 20:06:06 -0700 Subject: [PATCH 4/4] chore: remove debug console.log statements from sync processors --- .../sync/services/notify/handler/gcal.notification.handler.ts | 1 - .../backend/src/sync/services/sync/compass.sync.processor.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts b/packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts index ff45599b9..c8f03f14d 100644 --- a/packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts +++ b/packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts @@ -50,7 +50,6 @@ export class GCalNotificationHandler { const nextSyncToken = response.data.nextSyncToken; - console.log("LATEST CHANGES (from gcal):"); console.log(JSON.stringify(response.data, null, 2)); // If the nextSyncToken matches our current syncToken, we've already processed these changes diff --git a/packages/backend/src/sync/services/sync/compass.sync.processor.ts b/packages/backend/src/sync/services/sync/compass.sync.processor.ts index 72e3c9db9..26ae0ccc4 100644 --- a/packages/backend/src/sync/services/sync/compass.sync.processor.ts +++ b/packages/backend/src/sync/services/sync/compass.sync.processor.ts @@ -47,9 +47,6 @@ export class CompassSyncProcessor { ), ).then((events) => events.flat()); - console.log("LATEST CHANGES (from Compass):"); - console.log(JSON.stringify(compassEvents, null, 2)); - for (const event of compassEvents) { const changes = await CompassSyncProcessor.handleCompassChange( event,