Repository: SwitchbackTech/compass (ts)

Issue:
Add DB operations to recurrence flow
### Implementation
1. Trigger the correct handler based on the type of change based on the utility from #326 
2. Within the handler, add DB calls to UPSERT/DELETE accordingly

### Acceptance Criteria
- [x] Updates database correctly after being provided with an DELETE action
- [x] Updates database correctly after being provided with an UPSERT action



TESTING
- ~~Unit tests for event service that validate the correct query is triggered~~ This requires excessive mocking and setup. Better to do this as an integration test to abstract the business logic
- [x] Unit tests for the query to validate that they are making the right DB changes

Testing considerations
 - Validate it works when multiple events have changed (sometimes google batches multiple changes into one notification, so we can't assume it'll be 1:1 notification for event change)
- Test both all-day and timed recurring events

> [!NOTE]
>  Integration tests that connect this service to an actual Gcal notification are part of #328 




✨ Add DELETE ALL support
### Feature Description

When deleting all events in a recurrence series from GCal, all matching events in Compass should be deleted

### Use Case

_No response_

### Additional Context

_No response_

Pull request:
✨ Feat: DELETE ALL Compass events in series when user deleted in Gcal
Closes #351

Implements support for a recurrence series cancellation, so that when a user deletes all events in a series in GCal, the corresponding events are deleted in Compass.

Note: this only adds support for DELETE ALL. 
✅ DELETE ALL (this PR)
✅  DELETE ONE (past PR)
✅  UPDATE ONE (past PR)
❌ DELETE THIS AND FOLLOWING (future PR)
❌ UPDATE ALL (future PR)
❌ UPDATE THIS AND FOLLOWING (future PR)
 
https://github.com/user-attachments/assets/34b0b406-717a-4943-b6f1-4371f7d61fac

### Implementation Details
In order to support this feature, we needed to adjust how events are initially imported
1. Adds a `gRecurrentEventId` property to the event schema, which helps track which events were changed
2. Adjusts the event map based on ^
3. Connects instances to their base event via `event.recurrence.eventId`. We're doing this in one-pass, rather than looping over the events again and adding another DB operation. To make the one-shot approach work, we:
  -  pre-generate the ids with `assignEventIds` so an instance can be connected to a base at the time of its creation.
  - keep a map of base event ids and share it between pages during gcal fetching (`import.all.gcal` &`import.all.util`)
  - update the `eventService.createMany` to allow inserting events that have pre-defined ids
4. Fix the `cancelSeries` query to look for either the `_id` (base) or `recurrence.eventId` (instance) when deleting


Patch:
diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts
index d746b7d9..c307551c 100644
--- a/packages/backend/src/event/controllers/event.controller.ts
+++ b/packages/backend/src/event/controllers/event.controller.ts
@@ -23,7 +23,9 @@ class EventController {
           return validated;
         });
 
-        const response = await eventService.createMany(validatedEvents);
+        const response = await eventService.createMany(validatedEvents, {
+          stripIds: true, // Don't use client-generated ids (in case they are invalid)
+        });
         res.promise(response);
         return;
       }
diff --git a/packages/backend/src/event/queries/event.queries.ts b/packages/backend/src/event/queries/event.queries.ts
index e952ed97..ace5e6f4 100644
--- a/packages/backend/src/event/queries/event.queries.ts
+++ b/packages/backend/src/event/queries/event.queries.ts
@@ -10,7 +10,7 @@ import { error } from "@backend/common/errors/handlers/error.handler";
 import { getIdFilter } from "@backend/common/helpers/mongo.utils";
 import mongoService from "@backend/common/services/mongo.service";
 
-export type Ids_Event = "_id" | "gEventId";
+export type Ids_Event = "_id" | "gEventId" | "gRecurringEventId";
 
 /**
  * DB operations for Compass's Event collection, focused
diff --git a/packages/backend/src/event/queries/event.recur.queries.ts b/packages/backend/src/event/queries/event.recur.queries.ts
index 38837d3f..0099f900 100644
--- a/packages/backend/src/event/queries/event.recur.queries.ts
+++ b/packages/backend/src/event/queries/event.recur.queries.ts
@@ -11,30 +11,28 @@ import { Ids_Event } from "./event.queries";
 export class RecurringEventRepository {
   constructor(private userId: string) {}
 
+  /**
+   * Delete all events in the series (both base and instances)
+   * @param baseId - The _id of the base event
+   * @returns The result of the deleteMany operation
+   */
   async cancelSeries(baseId: string) {
-    // Delete all events in the series (both base and instances)
-
     const result = await mongoService.db
       .collection(Collections.EVENT)
       .deleteMany({
         $or: [
-          { _id: new ObjectId(baseId), user: this.userId }, // Base event
-          { "recurrence.eventId": baseId, user: this.userId }, // All instances
+          { _id: new ObjectId(baseId) },
+          { recurrence: { eventId: baseId } },
         ],
+        user: this.userId,
       });
 
-    const filters = [
-      { _id: baseId, user: this.userId },
-      { "recurrence.eventId": baseId, user: this.userId },
-    ];
-    console.log("series filters", filters);
     return result;
   }
 
   async cancelInstance(id: string, params?: { idKey: Ids_Event }) {
     const idKey = params?.idKey || "_id";
     const filter = { [idKey]: id, user: this.userId };
-    console.log("instance filter:", filter);
     // Delete just this specific instance
     const result = await mongoService.db
       .collection(Collections.EVENT)
diff --git a/packages/backend/src/event/services/event.service.ts b/packages/backend/src/event/services/event.service.ts
index bd1c3128..205480f1 100644
--- a/packages/backend/src/event/services/event.service.ts
+++ b/packages/backend/src/event/services/event.service.ts
@@ -1,3 +1,4 @@
+import { OptionalId } from "mongodb";
 import {
   Origin,
   Priorities,
@@ -29,6 +30,7 @@ import {
 import { error } from "@backend/common/errors/handlers/error.handler";
 import gcalService from "@backend/common/services/gcal/gcal.service";
 import mongoService from "@backend/common/services/mongo.service";
+import { Event_Core_WithObjectId } from "@backend/sync/sync.types";
 import { reorderEvents, updateEvent } from "../queries/event.queries";
 import {
   getCreateParams,
@@ -136,18 +138,23 @@ class EventService {
     return await this.create(userId, defaultWeekly);
   };
 
-  createMany = async (events: Event_Core[]) => {
-    const parsedEvents = events.map((e) => {
-      const cleanedEvent = {
-        ...e,
-        _id: undefined,
-      };
-      return cleanedEvent;
-    });
-
+  createMany = async (
+    events: Event_Core[] | Event_Core_WithObjectId[],
+    params: { stripIds: boolean },
+  ) => {
+    let eventsToInsert = events;
+    if (params.stripIds) {
+      eventsToInsert = events.map((e) => {
+        const cleanedEvent = {
+          ...e,
+          _id: undefined,
+        };
+        return cleanedEvent;
+      });
+    }
     const response = await mongoService.db
       .collection(Collections.EVENT)
-      .insertMany(parsedEvents);
+      .insertMany(eventsToInsert as unknown as OptionalId<Event_Core[]>[]);
 
     if (response.acknowledged && response.insertedCount !== events.length) {
       throw error(
diff --git a/packages/backend/src/sync/controllers/sync.debug.controller.ts b/packages/backend/src/sync/controllers/sync.debug.controller.ts
index a42feb52..46770a79 100644
--- a/packages/backend/src/sync/controllers/sync.debug.controller.ts
+++ b/packages/backend/src/sync/controllers/sync.debug.controller.ts
@@ -12,7 +12,6 @@ class SyncDebugController {
     try {
       const userId = process.env["SOCKET_USER"];
       if (!userId) {
-        console.log("No demo user");
         throw new Error("No demo user");
       }
       const startDate = new Date();
@@ -96,7 +95,6 @@ class SyncDebugController {
         gcal,
       );
 
-      console.log("++ watchResult", watchResult);
       res.promise(watchResult);
     } catch (e) {
       res.promise(Promise.reject(e));
diff --git a/packages/backend/src/sync/services/import/all/import.all.gcal.ts b/packages/backend/src/sync/services/import/all/import.all.gcal.ts
index 077c0f83..ac150a1b 100644
--- a/packages/backend/src/sync/services/import/all/import.all.gcal.ts
+++ b/packages/backend/src/sync/services/import/all/import.all.gcal.ts
@@ -1,3 +1,4 @@
+import { ObjectId } from "mongodb";
 import { Origin } from "@core/constants/core.constants";
 import { Logger } from "@core/logger/winston.logger";
 import { MapEvent } from "@core/mappers/map.event";
@@ -11,6 +12,7 @@ import { error } from "@backend/common/errors/handlers/error.handler";
 import gcalService from "@backend/common/services/gcal/gcal.service";
 import eventService from "@backend/event/services/event.service";
 import { Callback_EventProcessor, Map_ImportAll } from "../sync.import.types";
+import { assignIds } from "./import.all.util";
 
 const logger = Logger("sync.import.all.gcal");
 
@@ -23,7 +25,7 @@ export const fetchAndProcessEventsPageByPage = async (
   calendarId: string,
   gcalApiParams: gParamsImportAllEvents,
   // Callback function to process each event and decide if it should be saved
-  eventProcessor: Callback_EventProcessor,
+  shouldSave: Callback_EventProcessor,
   // Shared state accessible/modifiable by the processor
   sharedState: Map_ImportAll,
   captureSyncToken: boolean,
@@ -31,12 +33,14 @@ export const fetchAndProcessEventsPageByPage = async (
   savedCount: number;
   processedCount: number;
   nextSyncToken: string | undefined;
+  baseEventMap: Map<string, ObjectId>;
 }> => {
   let nextPageToken: string | undefined = undefined;
   let finalNextSyncToken: string | undefined = undefined;
   let totalProcessedApi = 0;
   let totalSaved = 0;
   const passIdentifier = gcalApiParams.singleEvents ? "Pass 2" : "Pass 1"; // For logging
+  let baseEventMap = sharedState.baseEventMap;
 
   logger.info(
     `${passIdentifier}: Fetching events for ${calendarId}. Params: ${JSON.stringify(gcalApiParams)}`,
@@ -66,18 +70,18 @@ export const fetchAndProcessEventsPageByPage = async (
           `${passIdentifier}: Initial fetch failed or returned invalid data for ${calendarId}`,
         );
       }
-      break; // Stop processing this pass/call
+      break;
     }
 
-    const eventsFromApi = gEventsResponse.data.items || []; // Ensure items is an array
-    totalProcessedApi += eventsFromApi.length;
+    const gEvents = gEventsResponse.data.items || [];
+    totalProcessedApi += gEvents.length;
 
-    if (eventsFromApi.length > 0) {
+    if (gEvents.length > 0) {
       const eventsToSave: gSchema$Event[] = [];
-      eventsFromApi.forEach((event) => {
+      gEvents.forEach((event) => {
         // Call the provided processor function
-        const shouldSave = eventProcessor(event, sharedState);
-        if (shouldSave) {
+        const _shouldSave = shouldSave(event, sharedState);
+        if (_shouldSave) {
           eventsToSave.push(event);
         }
       });
@@ -88,24 +92,16 @@ export const fetchAndProcessEventsPageByPage = async (
           eventsToSave,
           Origin.GOOGLE_IMPORT,
         );
+        baseEventMap = assignIds(cEvents, baseEventMap);
         if (cEvents.length > 0) {
-          try {
-            const result = await eventService.createMany(cEvents);
-            // Safely access insertedCount (assuming result object structure)
-            const savedCount =
-              result && typeof result.insertedCount === "number"
-                ? result.insertedCount
-                : 0;
-            totalSaved += savedCount;
-            // logger.debug(`${passIdentifier}: Saved ${savedCount} events this page.`); // Reduce noise
-          } catch (dbError) {
-            logger.error(
-              `${passIdentifier}: Error saving events to database.`,
-              { error: dbError, count: cEvents.length },
-            );
-            // Potentially re-throw if database errors should halt the entire sync
-            // throw dbError;
-          }
+          const result = await eventService.createMany(cEvents, {
+            stripIds: false, // Preserve our generated ids in order to match the gcal ids
+          });
+          const savedCount =
+            result && typeof result.insertedCount === "number"
+              ? result.insertedCount
+              : 0;
+          totalSaved += savedCount;
         }
       }
     }
@@ -128,5 +124,6 @@ export const fetchAndProcessEventsPageByPage = async (
     savedCount: totalSaved,
     processedCount: totalProcessedApi,
     nextSyncToken: finalNextSyncToken, // Will be undefined if captureSyncToken was false
+    baseEventMap,
   };
 };
diff --git a/packages/backend/src/sync/services/import/all/import.all.util.ts b/packages/backend/src/sync/services/import/all/import.all.util.ts
index 5d16644b..85003650 100644
--- a/packages/backend/src/sync/services/import/all/import.all.util.ts
+++ b/packages/backend/src/sync/services/import/all/import.all.util.ts
@@ -1,6 +1,9 @@
 import dayjs from "dayjs";
+import { ObjectId } from "mongodb";
 import { Logger } from "@core/logger/winston.logger";
+import { Event_Core } from "@core/types/event.types";
 import { gSchema$Event } from "@core/types/gcal";
+import { Event_Core_WithObjectId } from "@backend/sync/sync.types";
 import { Callback_EventProcessor } from "../sync.import.types";
 
 const logger = Logger("sync.import.all.util");
@@ -19,17 +22,17 @@ const getStartTimeString = (event: gSchema$Event): string | null => {
 
 // Processor for Pass 1: Identifies base/single events
 export const shouldProcessDuringPass1: Callback_EventProcessor = (
-  event,
+  gEvent,
   state,
 ) => {
-  if (event.id) {
-    if (event.recurrence) {
-      const startTime = getStartTimeString(event);
-      state.baseEventStartTimes.set(event.id, startTime);
-      state.processedEventIdsPass1.add(event.id);
+  if (gEvent.id) {
+    if (gEvent.recurrence) {
+      const startTime = getStartTimeString(gEvent);
+      state.baseEventStartTimes.set(gEvent.id, startTime);
+      state.processedEventIdsPass1.add(gEvent.id);
       return true; // Save base event
-    } else if (!event.recurringEventId) {
-      state.processedEventIdsPass1.add(event.id);
+    } else if (!gEvent.recurringEventId) {
+      state.processedEventIdsPass1.add(gEvent.id);
       return true; // Save single event
     }
   }
@@ -38,36 +41,82 @@ export const shouldProcessDuringPass1: Callback_EventProcessor = (
 
 // Processor for Pass 2: Filters events based on shared state
 export const shouldProcessDuringPass2: Callback_EventProcessor = (
-  event,
+  gEvent,
   state,
 ) => {
   // Filter 1: Skip event if already processed in Pass 1
-  if (state.processedEventIdsPass1.has(event.id || "")) {
-    logger.verbose(`Pass 2: Skipping event ${event.id} (processed in Pass 1).`); // Reduce noise
+  if (gEvent.id && state.processedEventIdsPass1.has(gEvent.id || "")) {
+    logger.verbose(
+      `Pass 2: Skipping  base event: ${gEvent.summary} (processed in Pass 1).`,
+    );
     return false; // Don't save
   }
 
   // Filter 2: Skip first instance if start matches base event start
-  if (event.recurringEventId && event.id) {
-    const baseStartTime = state.baseEventStartTimes.get(event.recurringEventId);
+  if (gEvent.recurringEventId && gEvent.id) {
+    const baseStartTime = state.baseEventStartTimes.get(
+      gEvent.recurringEventId,
+    );
     if (baseStartTime !== undefined) {
-      const instanceStartTime = getStartTimeString(event);
+      const instanceStartTime = getStartTimeString(gEvent);
       const isFirstInstance =
         baseStartTime &&
         instanceStartTime &&
         baseStartTime === instanceStartTime;
       if (isFirstInstance) {
         logger.verbose(
-          `Pass 2: Skipping event ${event.id} (first instance match).`,
+          `Pass 2: Skipping event ${gEvent.summary} (first instance match).`,
         ); // Reduce noise
         return false; // Don't save
       }
     } else {
       logger.warn(
-        `Pass 2: Instance ${event.id} found, base ${event.recurringEventId} unknown. Saving.`,
+        `Pass 2: Instance ${gEvent.id} found, base ${gEvent.recurringEventId} unknown. Saving.`,
       );
     }
   }
   // Event passed filters
   return true; // Save this event
 };
+
+/**
+ * Assigns Mongo ObjectIds to events (in-place) and links instances to their base events
+ * @param events - The events to assign ids to
+ */
+export const assignIds = (
+  events: Event_Core[] | Event_Core_WithObjectId[], // union type to allow for in-place id mutation in this function
+  existingBaseEventMap: Map<string, ObjectId> = new Map(),
+): Map<string, ObjectId> => {
+  // First pass: identify base events and assign their IDs
+  const baseEventMap = new Map(existingBaseEventMap);
+
+  // Handle base events first
+  events.forEach((event) => {
+    const id = new ObjectId();
+    if (event.recurrence?.rule && !event.gRecurringEventId) {
+      // This is a base event
+      event._id = id;
+      if (event.gEventId) {
+        baseEventMap.set(event.gEventId, id);
+      }
+    } else {
+      // This is a regular event or instance
+      event._id = id;
+    }
+  });
+
+  // Second pass: assign IDs to instances and link them to their base events
+  events.forEach((event) => {
+    if (event.gRecurringEventId) {
+      // This is an instance
+      const baseEventId = baseEventMap.get(event.gRecurringEventId);
+      if (baseEventId) {
+        event.recurrence = {
+          eventId: baseEventId.toString(),
+        };
+      }
+    }
+  });
+
+  return baseEventMap;
+};
diff --git a/packages/backend/src/sync/services/import/sync.import.ts b/packages/backend/src/sync/services/import/sync.import.ts
index 75001705..f1e01a9e 100644
--- a/packages/backend/src/sync/services/import/sync.import.ts
+++ b/packages/backend/src/sync/services/import/sync.import.ts
@@ -1,3 +1,4 @@
+import { ObjectId } from "mongodb";
 import { Origin } from "@core/constants/core.constants";
 import { Logger } from "@core/logger/winston.logger";
 import { MapEvent } from "@core/mappers/map.event";
@@ -98,15 +99,10 @@ export class SyncImport {
     calendarId: string,
     recurringEventId: string,
   ): Promise<Schema_Event_Core[]> {
-    // const timeMin = new Date().toISOString();
-    // const timeMax = dayjs().add(6, "months").toISOString();
-
     const { data } = await gcalService.getEventInstances(
       this.gcal,
       calendarId,
       recurringEventId,
-      // timeMin,
-      // timeMax,
     );
     const instances = data?.items;
 
@@ -115,17 +111,8 @@ export class SyncImport {
         `No instances found for recurring event ${recurringEventId} in calendar ${calendarId}`,
       );
       return [];
-      // TODO Cleanup if not needed
-      // throw error(
-      //   EventError.NoGevents,
-      //   "No instances found for recurring event",
-      // );
     }
 
-    // Potential Optimization: Check here if the first instance matches the base start time
-    // if you also fetch the base event details. However, this function is primarily used
-    // by the incremental sync (`categorizeGevents`), which seems to handle base vs instance separately.
-    // The fix for importAllEvents is applied directly within that function.
     return MapEvent.toCompass(userId, instances, Origin.GOOGLE_IMPORT);
   }
 
@@ -137,11 +124,6 @@ export class SyncImport {
     const expandedEvents: Schema_Event_Core[] = [];
 
     for (const event of recurringEvents) {
-      // This logic seems designed for incremental updates (syncToken based)
-      // where 'recurringEvents' might contain both base events and modified instances.
-
-      // If it's a base event with recurrence rules, map it directly.
-      // This assumes `organizeGcalEventsByType` correctly identifies these.
       if (event.recurrence && event.id) {
         const baseEvent = MapEvent.toCompass(
           userId,
@@ -155,14 +137,6 @@ export class SyncImport {
         continue;
       }
 
-      // If it's an updated *instance* of a recurring event (no .recurrence but has .recurringEventId)
-      // The current logic expands ALL instances based on the ID, which might be inefficient
-      // if only one instance changed. However, the Google sync token *should* only return
-      // the changed instance. Let's assume `organizeGcalEventsByType` puts individual
-      // modified instances into `toUpdate.nonRecurring` or handles them appropriately.
-      // This block might need review depending on how `organizeGcalEventsByType` works.
-      // If an event here *doesn't* have `event.recurrence` but *does* have `recurringEventId`,
-      // it's likely an individual instance received from the sync feed.
       if (event.recurringEventId && event.id) {
         const singleInstance = MapEvent.toCompass(
           userId,
@@ -179,21 +153,12 @@ export class SyncImport {
       // Fallback/Error case: If it's marked recurring but lacks necessary info
       const recurringId = event.recurringEventId || event.id;
       if (!recurringId) {
-        // logger.error(
-        //   "Recurring event received via sync is missing ID and recurringEventId.",
-        //   { eventSummary: event.summary },
-        // );
-        // continue;
         throw error(
           EventError.MissingProperty,
           "Recurring event not expanded due to missing recurrence id",
         );
       }
-      // This part might be unreachable if the above conditions handle all cases.
-      // If reached, it implies fetching all instances, which might be wrong for incremental sync.
-      logger.warn(
-        `Unexpectedly expanding all instances for ${recurringId} during incremental sync. Review logic if this occurs frequently.`,
-      );
+
       const instances = await this.expandRecurringEvent(
         userId,
         calendarId,
@@ -248,7 +213,6 @@ export class SyncImport {
     }
 
     if (!response) {
-      // Should ideally not happen if null is handled above, but keep for safety.
       throw error(
         SyncError.NoEventChanges,
         "Import ignorede due to no response from gcal",
@@ -256,8 +220,7 @@ export class SyncImport {
     }
 
     logger.debug(
-      // `++ Fetched updated events (syncToken: ${syncToken ? "..." : "N/A"}, pageToken: N/A) for ${gCalendarId}`, // Don't log full sync token
-      JSON.stringify(response.data, null, 2), // Avoid logging potentially large data unless debugging verbosely
+      JSON.stringify(response.data, null, 2), // TODO cleanup after testing
     );
     return response.data;
   }
@@ -274,6 +237,7 @@ export class SyncImport {
     const importMap: Map_ImportAll = {
       baseEventStartTimes: new Map<string, string | null>(),
       processedEventIdsPass1: new Set<string>(),
+      baseEventMap: new Map<string, ObjectId>(),
     };
 
     // Execute Pass 1
@@ -287,6 +251,9 @@ export class SyncImport {
       false, // Don't capture sync token in Pass 1
     );
 
+    // Update the shared state with the map from Pass 1
+    importMap.baseEventMap = pass1Result.baseEventMap;
+
     // Execute Pass 2
     const pass2Result = await fetchAndProcessEventsPageByPage(
       userId,
@@ -298,7 +265,6 @@ export class SyncImport {
       true, // Capture sync token in Pass 2
     );
 
-    // --- Finalization ---
     const nextSyncToken = pass2Result.nextSyncToken;
     if (!nextSyncToken) {
       // If Pass 2 ran but yielded no sync token (e.g., empty calendar or API issue on last page)
@@ -473,21 +439,6 @@ export class SyncImport {
 
     // Handle updates/creations (Upserts)
     if (toUpdate.length > 0) {
-      //TODO delete
-      // First check what documents exist
-      // const existingDocs = await mongoService.db
-      //   .collection(Collections.EVENT)
-      //   .find({
-      //     user: userId,
-      //     gEventId: { $in: toUpdate.map((e) => e["gEventId"]) },
-      //   })
-      //   .toArray();
-
-      // console.log(`Found ${existingDocs.length} existing events`);
-      // console.log(
-      //   `Existing event IDs: ${existingDocs.map((e) => e["gEventId"]).join(", ")}`,
-      // );
-
       const bulkOps = toUpdate.map((event) => ({
         updateOne: {
           filter: {
@@ -503,20 +454,6 @@ export class SyncImport {
         .collection(Collections.EVENT)
         .bulkWrite(bulkOps);
 
-      //TODo delete
-      // console.log(
-      //   `Bulk write result: ${JSON.stringify(
-      //     {
-      //       modifiedCount: result.modifiedCount,
-      //       upsertedCount: result.upsertedCount,
-      //       matchedCount: result.matchedCount,
-      //       upsertedIds: result.upsertedIds,
-      //     },
-      //     null,
-      //     2,
-      //   )}`,
-      // );
-
       // For upserts:
       // - modifiedCount: number of existing documents that were updated
       // - upsertedCount: number of documents that were inserted because they didn't exist
@@ -526,10 +463,6 @@ export class SyncImport {
 
     return { updated, deleted, created };
   }
-
-  // ================================================================
-  // Generic Helper Method for Page-by-Page Fetching and Processing (for importAllEvents)
-  // ================================================================
 }
 
 // Factory function to create instances
diff --git a/packages/backend/src/sync/services/import/sync.import.types.ts b/packages/backend/src/sync/services/import/sync.import.types.ts
index 62a6ac66..d6b48e7f 100644
--- a/packages/backend/src/sync/services/import/sync.import.types.ts
+++ b/packages/backend/src/sync/services/import/sync.import.types.ts
@@ -1,3 +1,4 @@
+import { ObjectId } from "mongodb";
 import { Schema_Event_Core } from "@core/types/event.types";
 import { gSchema$Event } from "@core/types/gcal";
 
@@ -11,7 +12,8 @@ export type Callback_EventProcessor = (
   sharedState: Map_ImportAll,
 ) => boolean; // Returns true if event should be saved
 
-export type Map_ImportAll = {
+export interface Map_ImportAll {
   baseEventStartTimes: Map<string, string | null>;
   processedEventIdsPass1: Set<string>;
-};
+  baseEventMap: Map<string, ObjectId>;
+}
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 a4327136..13552cdd 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
@@ -33,13 +33,12 @@ export class GCalNotificationHandler {
     );
 
     if (hasChanges) {
-      console.log("\nCHANGES TO PROCESS:", changes.length);
       const processor = new GcalSyncProcessor(this.repo);
       const changeSummary = await processor.processEvents(changes);
-      console.log("\nRESULT:", changeSummary);
+      console.log("PROCESSESD:", changeSummary);
       return { summary: "PROCESSED", changes: changeSummary };
     } else {
-      console.log("\nNO CHANGES TO PROCESS");
+      console.log("NO CHANGES TO PROCESS");
       return { summary: "IGNORED", changes: [] };
     }
   }
@@ -53,7 +52,7 @@ export class GCalNotificationHandler {
       syncToken,
     });
 
-    console.log("\nLATEST CHANGES:");
+    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/gcal.sync.processor.ts b/packages/backend/src/sync/services/sync/gcal.sync.processor.ts
index 6a27e4ef..f613b9ab 100644
--- a/packages/backend/src/sync/services/sync/gcal.sync.processor.ts
+++ b/packages/backend/src/sync/services/sync/gcal.sync.processor.ts
@@ -1,5 +1,5 @@
 import { Logger } from "@core/logger/winston.logger";
-import { Schema_Event } from "@core/types/event.types";
+import { Event_Core } from "@core/types/event.types";
 import { gSchema$Event } from "@core/types/gcal";
 import { EventError } from "@backend/common/constants/error.constants";
 import { error } from "@backend/common/errors/handlers/error.handler";
@@ -16,14 +16,14 @@ export class GcalSyncProcessor {
   async processEvents(events: gSchema$Event[]): Promise<Change_Gcal[]> {
     const summary: Change_Gcal[] = [];
     console.log(`Processing ${events.length} event(s)...`);
-    for (const event of events) {
-      const parser = new GcalParser(event);
+    for (const gEvent of events) {
+      const parser = new GcalParser(gEvent);
       const category = parser.category;
       const status = parser.status;
       const change = parser.summarize();
       let operation: Operation_Sync = null;
 
-      if (!event.id) {
+      if (!gEvent.id) {
         throw error(
           EventError.MissingGevents,
           "Event not processed due to missing id",
@@ -32,26 +32,26 @@ export class GcalSyncProcessor {
 
       // --- Handle cancellation ---
       if (status === "CANCELLED") {
-        const compassEvent = await this.getCompassEvent(event.id);
+        const compassEvent = await this.getCompassEvent(gEvent.id);
         if (!compassEvent) {
           logger.warn(
             "Not processing this event, because it was not found in DB:",
-            event.summary,
+            gEvent.summary,
           );
           continue;
         }
 
-        if (isBase(compassEvent as Schema_Event)) {
+        if (isBase(compassEvent as Event_Core)) {
           console.log(
-            `Cancelling series: ${compassEvent._id} (Compass) | ${event.id} (Gcal)`,
+            `Cancelling SERIES: ${compassEvent._id} (Compass) | ${gEvent.id} (Gcal)`,
           );
           await this.repo.cancelSeries(compassEvent._id.toString());
           operation = "DELETED";
         } else {
           console.log(
-            `Cancelling instance: ${compassEvent._id} (Compass) | ${event.id} (Gcal)`,
+            `Cancelling INSTANCE: ${compassEvent._id} (Compass) | ${gEvent.id} (Gcal)`,
           );
-          await this.repo.cancelInstance(event.id, { idKey: "gEventId" });
+          await this.repo.cancelInstance(gEvent.id, { idKey: "gEventId" });
           operation = "DELETED";
         }
       }
diff --git a/packages/backend/src/sync/sync.types.ts b/packages/backend/src/sync/sync.types.ts
index db5c13f4..7e028097 100644
--- a/packages/backend/src/sync/sync.types.ts
+++ b/packages/backend/src/sync/sync.types.ts
@@ -1,5 +1,9 @@
-import { Categories_Recurrence } from "@core/types/event.types";
+import { ObjectId } from "mongodb";
+import { Categories_Recurrence, Event_Core } from "@core/types/event.types";
 
+export type Event_Core_WithObjectId = Omit<Event_Core, "_id"> & {
+  _id?: ObjectId;
+};
 export type Summary_Sync = {
   summary: "PROCESSED" | "IGNORED";
   changes: Change_Gcal[];
diff --git a/packages/core/src/mappers/map.event.ts b/packages/core/src/mappers/map.event.ts
index 44572f24..7c4a9890 100644
--- a/packages/core/src/mappers/map.event.ts
+++ b/packages/core/src/mappers/map.event.ts
@@ -59,7 +59,10 @@ const _toCompass = (
       ? origin
       : gEvent.extendedProperties?.private?.["origin"] || Origin.UNSURE;
 
-  const gEventId = gEvent.id ? gEvent.id : "uh oh";
+  const gEventId = gEvent.id ? gEvent.id : undefined;
+  const gRecurringEventId = gEvent.recurringEventId
+    ? gEvent.recurringEventId
+    : undefined;
   const title = gEvent.summary ? gEvent.summary : "untitled";
   const description = gEvent.description ? gEvent.description : "";
 
@@ -83,18 +86,9 @@ const _toCompass = (
       ? Priorities.UNASSIGNED
       : (_origPriority as Priority);
 
-  const recurrence = gEvent.recurrence
-    ? {
-        rule: gEvent.recurrence,
-      }
-    : gEvent.recurringEventId
-      ? {
-          eventId: gEvent.recurringEventId,
-        }
-      : undefined;
-
   const compassEvent: Schema_Event = {
     gEventId: gEventId,
+    gRecurringEventId,
     user: userId,
     origin: _origin as Origin,
     title: title,
@@ -107,10 +101,26 @@ const _toCompass = (
     // @ts-ignore
     endDate: _isAllDay ? _end.date : _end.dateTime,
     priority: _priority,
-    ...(recurrence ? { recurrence } : {}),
     updatedAt: new Date(),
   };
 
+  const recurrence = getRecurrence(gEvent);
+  // Only add recurrence if it's defined
+  if (recurrence) {
+    compassEvent.recurrence = recurrence;
+  }
+
   const validatedCompassEvent = validateEvent(compassEvent);
   return validatedCompassEvent;
 };
+
+const getRecurrence = (gEvent: gSchema$Event) => {
+  const recurrenceExists =
+    gEvent.recurrence !== undefined && gEvent.recurrence !== null;
+  if (recurrenceExists) {
+    return {
+      rule: gEvent.recurrence as string[],
+    };
+  }
+  return undefined;
+};
diff --git a/packages/core/src/types/event.types.ts b/packages/core/src/types/event.types.ts
index 2acfdb6a..1c511046 100644
--- a/packages/core/src/types/event.types.ts
+++ b/packages/core/src/types/event.types.ts
@@ -60,6 +60,7 @@ export interface Schema_Event {
   isAllDay?: boolean;
   isSomeday?: boolean;
   gEventId?: string;
+  gRecurringEventId?: string;
   order?: number;
   origin?: Origin;
   priority?: Priority;
@@ -117,6 +118,7 @@ export const CoreEventSchema = z.object({
   isAllDay: z.boolean().optional(),
   isSomeday: z.boolean().optional(),
   gEventId: z.string().optional(),
+  gRecurringEventId: z.string().optional(),
   origin: z.nativeEnum(Origin),
   priority: z.nativeEnum(Priorities),
   recurrence: Recurrence.optional(),


Test patch:
diff --git a/packages/backend/src/__tests__/helpers/mock.db.queries.ts b/packages/backend/src/__tests__/helpers/mock.db.queries.ts
index de1bbe68..e2ed36d7 100644
--- a/packages/backend/src/__tests__/helpers/mock.db.queries.ts
+++ b/packages/backend/src/__tests__/helpers/mock.db.queries.ts
@@ -1,6 +1,17 @@
+import { Event_Core } from "@core/types/event.types";
 import { Collections } from "@backend/common/constants/collections";
 import mongoService from "@backend/common/services/mongo.service";
 
+export const getCategorizedEventsInDb = async () => {
+  const allEvents = (await getEventsInDb()) as unknown as Event_Core[];
+  const baseEvents = allEvents.filter((e) => e.recurrence?.rule !== undefined);
+  const instanceEvents = allEvents.filter(
+    (e) => e.recurrence?.eventId !== undefined,
+  );
+  const regularEvents = allEvents.filter((e) => e.recurrence === undefined);
+  return { baseEvents, instanceEvents, regularEvents };
+};
+
 export const getEventsInDb = async () => {
   return await mongoService.db.collection(Collections.EVENT).find().toArray();
 };
diff --git a/packages/backend/src/__tests__/mocks.ccal/ccal.event.factory.ts b/packages/backend/src/__tests__/mocks.ccal/ccal.event.factory.ts
index 1aaf12fe..70b9d2e1 100644
--- a/packages/backend/src/__tests__/mocks.ccal/ccal.event.factory.ts
+++ b/packages/backend/src/__tests__/mocks.ccal/ccal.event.factory.ts
@@ -60,6 +60,7 @@ export const createMockInstance = (
     isSomeday: false,
     updatedAt: now,
     gEventId: `mock-gcal-id-${faker.string.uuid()}`,
+    gRecurringEventId: baseEventId,
     ...overrides,
   };
 };
diff --git a/packages/backend/src/sync/services/import/all/import.all.util.test.ts b/packages/backend/src/sync/services/import/all/import.all.util.test.ts
new file mode 100644
index 00000000..df94c5e6
--- /dev/null
+++ b/packages/backend/src/sync/services/import/all/import.all.util.test.ts
@@ -0,0 +1,50 @@
+import { ObjectId } from "mongodb";
+import { Event_Core } from "@core/types/event.types";
+import { createMockBaseEvent } from "@backend/__tests__/mocks.ccal/ccal.event.factory";
+import { createMockInstance } from "@backend/__tests__/mocks.ccal/ccal.event.factory";
+import { assignIds } from "./import.all.util";
+
+const getEventsWithoutIds = () => {
+  // Create a base event with a Google ID
+  const baseEvent = createMockBaseEvent();
+  baseEvent.gEventId = "google_base_id";
+  delete baseEvent._id;
+
+  // Create instances that reference the base event's Google ID
+  const instance1 = createMockInstance("google_base_id");
+  const instance2 = createMockInstance("google_base_id");
+  // delete _ids to simulate a newly mapped event
+  delete instance1._id;
+  delete instance2._id;
+
+  const eventsWithoutIds = [baseEvent, instance1, instance2];
+  return eventsWithoutIds;
+};
+
+describe("assignIds", () => {
+  it("assigns Mongo ObjectIds to events", () => {
+    const events = getEventsWithoutIds() as Event_Core[];
+    assignIds(events);
+
+    // Verify all events have ObjectIds
+    for (const event of events) {
+      expect(event?._id).toBeDefined();
+      expect(event._id).toBeInstanceOf(ObjectId);
+    }
+  });
+  it("links instances to base events", () => {
+    const events = getEventsWithoutIds() as Event_Core[]; // no ids originally
+    assignIds(events); // adds ids
+
+    const baseEvent = events.filter((event) => event.recurrence?.rule);
+    expect(baseEvent).toHaveLength(1);
+
+    const instances = events.filter((event) => event.recurrence?.eventId);
+    expect(instances).toHaveLength(2);
+
+    for (const instance of instances) {
+      const baseEventId = baseEvent[0]!._id?.toString();
+      expect(instance.recurrence?.eventId).toBe(baseEventId);
+    }
+  });
+});
diff --git a/packages/backend/src/sync/services/import/sync.import.test.ts b/packages/backend/src/sync/services/import/sync.import.test.ts
index e7e615f9..90cae860 100644
--- a/packages/backend/src/sync/services/import/sync.import.test.ts
+++ b/packages/backend/src/sync/services/import/sync.import.test.ts
@@ -1,3 +1,4 @@
+import { getCategorizedEventsInDb } from "@backend/__tests__/helpers/mock.db.queries";
 import {
   cleanupTestMongo,
   clearCollections,
@@ -13,6 +14,7 @@ const createTestEvents = () => {
   // Create a base recurring event
   const baseRecurringEvent = mockGcalEvent({
     id: "recurring-1",
+    summary: "Recurrence Base",
     recurrence: ["RRULE:FREQ=WEEKLY"],
   });
 
@@ -20,25 +22,29 @@ const createTestEvents = () => {
   const instances = [
     mockGcalEvent({
       id: "recurring-1-instance-1",
-      recurringEventId: "recurring-1",
+      summary: "Recurrence Instance 1",
+      recurringEventId: baseRecurringEvent.id,
       // the first instance shares the same start date as the base event
       start: baseRecurringEvent.start,
     }),
     mockGcalEvent({
       id: "recurring-1-instance-2",
-      recurringEventId: "recurring-1",
+      summary: "Recurrence Instance 2",
+      recurringEventId: baseRecurringEvent.id,
     }),
   ];
 
   // Create a regular event
   const regularEvent = mockGcalEvent({
     id: "regular-1",
+    summary: "Regular Event",
   });
 
   // Create a cancelled event
   const cancelledEvent = mockGcalEvent({
     id: "cancelled-1",
     status: "cancelled",
+    summary: "Cancelled Event",
   });
 
   return {
@@ -95,7 +101,7 @@ describe("SyncImport", () => {
       const instanceEvents = currentEventsInDb.filter(
         (e) => e.recurrence?.eventId !== undefined,
       );
-      expect(instanceEvents).toHaveLength(1); // 2 instances total, but 1 is skipped
+      expect(instanceEvents).toHaveLength(1); // 2 instances total, but 1 is skipped because it has the same start date as the base event
       expect(instanceEvents.map((e) => e.gEventId)).toEqual(
         expect.arrayContaining(["recurring-1-instance-2"]),
       );
@@ -149,4 +155,14 @@ describe("SyncImport", () => {
     const firstInstanceStart = recurringEvents[1]?.startDate;
     expect(baseStart).not.toEqual(firstInstanceStart);
   });
+
+  it("should connect instances to their base events", async () => {
+    await syncImport.importAllEvents(setup.userId, "test-calendar");
+    const { baseEvents, instanceEvents } = await getCategorizedEventsInDb();
+
+    expect(instanceEvents).toHaveLength(1); // 2 instances total, but 1 is skipped because it has the same start date as the base event
+    expect(instanceEvents[0]?.recurrence?.eventId).toBe(
+      baseEvents[0]?._id?.toString(),
+    );
+  });
 });
diff --git a/packages/core/src/__tests__/mappers/map.calendarlist.test.ts b/packages/core/src/__tests__/mappers/map.calendarlist.test.ts
deleted file mode 100644
index 823bb95c..00000000
--- a/packages/core/src/__tests__/mappers/map.calendarlist.test.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { gcalCalendarList } from "../../__mocks__/calendarlist/gcal.calendarlist";
-import { MapCalendarList } from "../../mappers/map.calendarlist";
-
-it("supports multiple calendars", () => {
-  const ccallist = MapCalendarList.toCompass(gcalCalendarList);
-  expect(ccallist.google.items.length).toEqual(1);
-  expect(ccallist.google.items[0].items.length).toBeGreaterThan(1);
-});
diff --git a/packages/core/src/mappers/map.calendarlist.test.ts b/packages/core/src/mappers/map.calendarlist.test.ts
new file mode 100644
index 00000000..aac35b78
--- /dev/null
+++ b/packages/core/src/mappers/map.calendarlist.test.ts
@@ -0,0 +1,8 @@
+import { gcalCalendarList } from "../__mocks__/calendarlist/gcal.calendarlist";
+import { MapCalendarList } from "./map.calendarlist";
+
+it("supports multiple calendars", () => {
+  const ccallist = MapCalendarList.toCompass(gcalCalendarList);
+  expect(ccallist.google.items).toHaveLength(1);
+  expect(ccallist.google.items[0].items.length).toBeGreaterThan(1);
+});
diff --git a/packages/core/src/__tests__/mappers/map.event.test.ts b/packages/core/src/mappers/map.event.test.ts
similarity index 86%
rename from packages/core/src/__tests__/mappers/map.event.test.ts
rename to packages/core/src/mappers/map.event.test.ts
index cb465069..b828a335 100644
--- a/packages/core/src/__tests__/mappers/map.event.test.ts
+++ b/packages/core/src/mappers/map.event.test.ts
@@ -1,9 +1,11 @@
 import { recurring } from "@core/__mocks__/events/gcal/gcal.recurring";
+import { timed } from "@core/__mocks__/events/gcal/gcal.timed";
 import { Schema_Event } from "@core/types/event.types";
 import { gSchema$Event } from "@core/types/gcal";
-import { gcalEvents } from "../../__mocks__/events/gcal/gcal.event";
-import { Origin, Priorities } from "../../constants/core.constants";
-import { MapEvent } from "../../mappers/map.event";
+import { mockGcalEvent } from "@backend/__tests__/mocks.gcal/factories/gcal.event.factory";
+import { gcalEvents } from "../__mocks__/events/gcal/gcal.event";
+import { Origin, Priorities } from "../constants/core.constants";
+import { MapEvent } from "./map.event";
 
 describe("toGcal", () => {
   const validGcalDates = [
@@ -183,6 +185,40 @@ describe("toCompass", () => {
   });
 
   describe("recurrence", () => {
+    it("does not include gRecurringEventId for regular events", () => {
+      const regularGEvent = timed[0] as gSchema$Event;
+      const cEvent = MapEvent.toCompass(
+        "user1",
+        [regularGEvent],
+        Origin.COMPASS,
+      )[0];
+      if (!cEvent) {
+        throw new Error("Failed to map event");
+      }
+
+      expect(cEvent.gRecurringEventId).toBeUndefined();
+    });
+    it("includes recurrence when rule is present", () => {
+      const gEvent = recurring[0]?.items[0] as gSchema$Event | undefined;
+      if (!gEvent) {
+        throw new Error("Test event not found in mock data");
+      }
+
+      const cEvent = MapEvent.toCompass(
+        "user1",
+        [gEvent],
+        Origin.GOOGLE_IMPORT,
+      )[0];
+
+      if (!cEvent) {
+        throw new Error("Failed to map event");
+      }
+
+      expect(cEvent.recurrence).toBeDefined();
+      expect(cEvent.recurrence).not.toBeNull();
+      expect(cEvent.recurrence?.rule).toEqual(["RRULE:FREQ=DAILY"]);
+      expect(cEvent.recurrence?.eventId).toBeUndefined();
+    });
     it("does not include both recurrence rule and id simultaneously", () => {
       const gEvent = {
         kind: "calendar#event",
@@ -227,15 +263,15 @@ describe("toCompass", () => {
       expect(cEvent.recurrence).toBeDefined();
       expect(cEvent.recurrence?.eventId).toBeUndefined();
     });
-    it("includes recurrence for instances of recurring events", () => {
-      const gEvent = recurring[0]?.items[1] as gSchema$Event | undefined;
-      if (!gEvent) {
+    it("stores the recurringEventId (gcal) as gRecurringEventId (Compass)", () => {
+      const gEventInstance = recurring[0]?.items[1] as gSchema$Event;
+      if (!gEventInstance) {
         throw new Error("Test event not found in mock data");
       }
 
       const cEvent = MapEvent.toCompass(
         "user1",
-        [gEvent],
+        [gEventInstance],
         Origin.GOOGLE_IMPORT,
       )[0];
 
@@ -243,29 +279,12 @@ describe("toCompass", () => {
         throw new Error("Failed to map event");
       }
 
-      expect(cEvent.recurrence).toBeDefined();
+      expect(cEvent.recurrence).toBeUndefined();
       expect(cEvent.recurrence?.rule).toBeUndefined();
-      expect(cEvent.recurrence?.eventId).toBe(gEvent.recurringEventId);
-    });
-    it("includes recurrence when rule is present", () => {
-      const gEvent = recurring[0]?.items[0] as gSchema$Event | undefined;
-      if (!gEvent) {
-        throw new Error("Test event not found in mock data");
-      }
-
-      const cEvent = MapEvent.toCompass(
-        "user1",
-        [gEvent],
-        Origin.GOOGLE_IMPORT,
-      )[0];
-
-      if (!cEvent) {
-        throw new Error("Failed to map event");
-      }
-
-      expect(cEvent.recurrence).toBeDefined();
-      expect(cEvent.recurrence?.rule).toEqual(["RRULE:FREQ=DAILY"]);
-      expect(cEvent.recurrence?.eventId).toBeUndefined();
+      expect(cEvent.recurrence?.eventId).not.toBe(
+        gEventInstance.recurringEventId,
+      );
+      expect(cEvent.gRecurringEventId).toBe(gEventInstance.recurringEventId);
     });
   });
 });
diff --git a/packages/core/src/__tests__/mappers/map.user.test.ts b/packages/core/src/mappers/map.user.test.ts
similarity index 90%
rename from packages/core/src/__tests__/mappers/map.user.test.ts
rename to packages/core/src/mappers/map.user.test.ts
index fe4242ef..e3cd4194 100644
--- a/packages/core/src/__tests__/mappers/map.user.test.ts
+++ b/packages/core/src/mappers/map.user.test.ts
@@ -1,5 +1,5 @@
-import { BaseError } from "../../errors/errors.base";
-import { mapUserToCompass } from "../../mappers/map.user";
+import { BaseError } from "../errors/errors.base";
+import { mapUserToCompass } from "./map.user";
 
 describe("Map to Compass", () => {
   it("adds placeholders for acceptible fields", () => {