From 50c04ff283070255e3f5710b0446ad3ee8b6a189 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Mon, 2 Feb 2026 19:08:10 -0800 Subject: [PATCH] feat(sync): add instanceToStandalone conversion for Google Calendar events - Implemented `instanceToStandalone` method in `GcalEventParser` to convert recurrence instances into standalone events, enhancing event management capabilities. - Updated `GcalSyncProcessor` to handle the new conversion case in the event processing logic. - Added unit tests to validate the functionality of detaching instances into standalone events, ensuring correct behavior and data integrity during synchronization. This change improves the flexibility of the Google Calendar synchronization process, allowing users to manage their events more effectively. --- .../src/event/classes/gcal.event.parser.ts | 23 +++++++++++ ...cal.sync.processor.upsert.instance.test.ts | 41 +++++++++++++++++++ .../sync/services/sync/gcal.sync.processor.ts | 2 + 3 files changed, 66 insertions(+) diff --git a/packages/backend/src/event/classes/gcal.event.parser.ts b/packages/backend/src/event/classes/gcal.event.parser.ts index b7910cbe4..c2c4ba8ca 100644 --- a/packages/backend/src/event/classes/gcal.event.parser.ts +++ b/packages/backend/src/event/classes/gcal.event.parser.ts @@ -281,6 +281,29 @@ export class GcalEventParser { return [...seriesChanges, ...baseChanges]; } + async instanceToStandalone( + session?: ClientSession, + ): Promise { + const event = gEventToCompassEvent(this.#event, this.userId); + + const { + recurrence, // eslint-disable-line @typescript-eslint/no-unused-vars + gRecurringEventId, // eslint-disable-line @typescript-eslint/no-unused-vars + ...eventWithoutProps + } = event; + + return this.upsertCompassEvent( + { + $unset: { recurrence: 1, gRecurringEventId: 1 }, + $set: { + ...eventWithoutProps, + updatedAt: new Date(), + }, + }, + session, + ); + } + async standaloneToSeries(session?: ClientSession) { this.#logger.info( `UPDATING ${this.getTransitionString()}: ${this.#event.id} to series`, diff --git a/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.instance.test.ts b/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.instance.test.ts index 96d2d890e..0a902987b 100644 --- a/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.instance.test.ts +++ b/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.instance.test.ts @@ -1,4 +1,5 @@ import { Categories_Recurrence } from "@core/types/event.types"; +import { gSchema$Event } from "@core/types/gcal"; import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; import { cleanupCollections, @@ -60,6 +61,46 @@ describe("GcalSyncProcessor UPSERT: INSTANCE", () => { expect(updatedInstance?.title).toEqual(instanceTitle); }); + it("should handle DETACHING an INSTANCE into a STANDALONE event", async () => { + const { user } = await UtilDriver.setupTestUser(); + + const { gcalEvents } = await simulateDbAfterGcalImport(user._id.toString()); + + const origInstance = gcalEvents.instances[0]; + + const standalone = { + ...origInstance, + summary: "Detached Instance Event", + } as gSchema$Event; + + delete (standalone as { recurringEventId?: string }).recurringEventId; + delete (standalone as { recurrence?: string[] }).recurrence; + + const processor = new GcalSyncProcessor(user._id.toString()); + const changes = await processor.processEvents([standalone]); + + expect(changes).toHaveLength(1); + expect(changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: standalone.summary, + category: Categories_Recurrence.RECURRENCE_INSTANCE, + transition: ["RECURRENCE_INSTANCE", "STANDALONE_CONFIRMED"], + operation: "RECURRENCE_INSTANCE_UPDATED", + }), + ]), + ); + + const updatedStandalone = await mongoService.event.findOne({ + gEventId: standalone.id!, + user: user._id.toString(), + }); + + expect(updatedStandalone).toBeDefined(); + expect(updatedStandalone?.gRecurringEventId).toBeUndefined(); + expect(updatedStandalone?.recurrence).toBeUndefined(); + }); + it("should handle UPDATING a REGULAR, BASE and TIMED INSTANCE", async () => { const { user } = await UtilDriver.setupTestUser(); diff --git a/packages/backend/src/sync/services/sync/gcal.sync.processor.ts b/packages/backend/src/sync/services/sync/gcal.sync.processor.ts index ff2c2dead..b2fafc831 100644 --- a/packages/backend/src/sync/services/sync/gcal.sync.processor.ts +++ b/packages/backend/src/sync/services/sync/gcal.sync.processor.ts @@ -80,6 +80,8 @@ export class GcalSyncProcessor { return parser.createSeries(session); case "STANDALONE->>RECURRENCE_BASE_CONFIRMED": return parser.standaloneToSeries(session); + case "RECURRENCE_INSTANCE->>STANDALONE_CONFIRMED": + return parser.instanceToStandalone(session); case "RECURRENCE_BASE->>STANDALONE_CONFIRMED": return parser.seriesToStandalone(session); case "RECURRENCE_INSTANCE->>RECURRENCE_BASE_CONFIRMED":