Repository: SwitchbackTech/compass (ts)

Issue:
UPSERT INSTANCE isn't processing change in Compass after changing in Gcal
### Where did this happen?

Production app

### Expected Behavior

When UPDATING / DELETING an instance in Gcal, the instance should be updated in Compass

### Current Behavior

The Compass instance stays the same

### Steps to Reproduce

1. Create a recurring event in Gcal
2. Change one of the events (just one)
3. Notice: the change isn't reflected in Compass


Pull request description:
🐛 Fix: preserve instance gEventId during update
### Summary

Closes #385, which was caused by
- a prop being overridden by the base, resulting in the target instance not being updated
- using strings instead of `ObjectId` when assigning ids 

Adds utility and schema for better typechecking on the backend


### Status of Recurrence Feature (Readonly)
✅ DELETE ALL
✅ DELETE ONE
✅ DELETE THIS AND FOLLOWING
✅ CREATE ALL
✅ UPDATE ONE REGULAR
✅  UPDATE ONE INSTANCE (this PR)
✅ UPDATE ALL
✅ UPDATE THIS AND FOLLOWING
❌ parses custom RRULEs
❌ bugs: #378 and likely others


----
This pull request introduces significant changes to improve type safety, validation, and modularity in handling event data, particularly focusing on recurring events and their synchronization. Key updates include the introduction of a new `Event_API` type, enhanced validation mechanisms, and the refactoring of utility functions for better testability and maintainability.

### Type System Enhancements:
* Introduced a new `Event_API` type and its corresponding Zod schema `Schema_Event_API` to represent events as stored in the database, improving type safety and consistency (`packages/backend/src/common/types/backend.event.types.ts`).
* Updated the return type of `getEventsInDb` to use `Event_API[]` instead of `WithCompassId<Event_Core>[]`, aligning with the new type system (`packages/backend/src/__tests__/helpers/mock.db.queries.ts`).

### Validation Improvements:
* Added a reusable `safeValidate` function for schema-based validation using Zod, ensuring robust and type-safe validation of event data (`packages/backend/src/common/validators/validate.ts`).
* Created a `validateEventSafely` function to validate `Event_API` objects against the `Schema_Event_API`, enabling safer event processing (`packages/backend/src/common/validators/validate.event.ts`).

### Refactoring and Utility Updates:
* Refactored the `stripBaseProps` function to remove prohibited properties from recurring base events and relocated it to a dedicated utility file for better modularity (`packages/backend/src/event/services/recur/util/recur.util.ts`).
* Added a new test suite for `stripBaseProps` to ensure its correctness and reliability (`packages/backend/src/event/services/recur/util/recur.util.test.ts`).

### Synchronization Enhancements:
* Updated the `SyncImport` class to replace string-based `_id` fields with MongoDB `ObjectId` types, improving database consistency and type alignment (`packages/backend/src/sync/services/import/sync.import.ts`). [[1]](diffhunk://#diff-6a6368e54fd4ea9da9158798c3e2cf5dc4b0d2c38533c2fa0ebf5e5e5d8b4832R48-R53) [[2]](diffhunk://#diff-6a6368e54fd4ea9da9158798c3e2cf5dc4b0d2c38533c2fa0ebf5e5e5d8b4832L82-R98)
* Added validation checks to ensure that events imported during synchronization conform to the `Event_API` schema (`packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.base.test.ts`).

### Testing Improvements:
* Enhanced test utilities by adding functions to validate event schemas and check date formats, ensuring comprehensive test coverage for synchronization logic (`packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.test.util.ts`). [[1]](diffhunk://#diff-50c5a1997893515b4f4e74854523bcef00ab7edfd138707bc88e7bcfde3b898cR74-R108) [[2]](diffhunk://#diff-50c5a1997893515b4f4e74854523bcef00ab7edfd138707bc88e7bcfde3b898cL125-L174)

Patch:
diff --git a/packages/backend/src/common/types/backend.event.types.ts b/packages/backend/src/common/types/backend.event.types.ts
new file mode 100644
index 00000000..96a1747a
--- /dev/null
+++ b/packages/backend/src/common/types/backend.event.types.ts
@@ -0,0 +1,15 @@
+import { ObjectId } from "mongodb";
+import { z } from "zod";
+import { CoreEventSchema } from "@core/types/event.types";
+
+const zObjectId = z.instanceof(ObjectId);
+
+/**
+ * Schema for internal events that match how they
+ * are saved to the DB
+ */
+export const Schema_Event_API = CoreEventSchema.extend({
+  _id: zObjectId.optional(),
+});
+
+export type Event_API = z.infer<typeof Schema_Event_API>;
diff --git a/packages/backend/src/common/validators/validate.event.ts b/packages/backend/src/common/validators/validate.event.ts
new file mode 100644
index 00000000..91b4e44f
--- /dev/null
+++ b/packages/backend/src/common/validators/validate.event.ts
@@ -0,0 +1,10 @@
+import {
+  Event_API,
+  Schema_Event_API,
+} from "@backend/common/types/backend.event.types";
+import { safeValidate } from "./validate";
+
+export const validateEventSafely = (event: Event_API) => {
+  const result = safeValidate(Schema_Event_API, event);
+  return result;
+};
diff --git a/packages/backend/src/common/validators/validate.ts b/packages/backend/src/common/validators/validate.ts
new file mode 100644
index 00000000..8eb1a83c
--- /dev/null
+++ b/packages/backend/src/common/validators/validate.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const safeValidate = <T>(schema: z.ZodType<T>, data: unknown) => {
+  const result = schema.safeParse(data);
+
+  return result;
+};
diff --git a/packages/backend/src/event/services/recur/repo/recur.event.repo.util.ts b/packages/backend/src/event/services/recur/repo/recur.event.repo.util.ts
index 8b23a070..f9787766 100644
--- a/packages/backend/src/event/services/recur/repo/recur.event.repo.util.ts
+++ b/packages/backend/src/event/services/recur/repo/recur.event.repo.util.ts
@@ -5,6 +5,7 @@ import { EventError } from "@backend/common/constants/error.constants";
 import { error } from "@backend/common/errors/handlers/error.handler";
 import mongoService from "@backend/common/services/mongo.service";
 import { Ids_Event } from "../../../queries/event.queries";
+import { stripBaseProps } from "../util/recur.util";
 
 export const getOldBaseId = async (
   updatedBase: Schema_Event_Recur_Base,
@@ -25,20 +26,6 @@ export const getOldBaseId = async (
   return baseId;
 };
 
-export const stripBaseProps = (base: Schema_Event_Recur_Base) => {
-  const {
-    recurrence,
-    updatedAt,
-    _id,
-    startDate,
-    endDate,
-    order,
-    user,
-    ...baseForUpdate
-  } = base;
-  return baseForUpdate;
-};
-
 export const updateAllDayInstances = async (
   baseId: ObjectId,
   updatedBase: Schema_Event_Recur_Base,
diff --git a/packages/backend/src/event/services/recur/util/recur.util.ts b/packages/backend/src/event/services/recur/util/recur.util.ts
index fa57d758..9516933d 100644
--- a/packages/backend/src/event/services/recur/util/recur.util.ts
+++ b/packages/backend/src/event/services/recur/util/recur.util.ts
@@ -36,6 +36,21 @@ export const assembleInstances = (
   return events;
 };
 
+export const stripBaseProps = (base: Schema_Event_Recur_Base) => {
+  const {
+    _id, // preserve existing event
+    gEventId, // strip this so instances remain unique
+    startDate, // dates changed during DB update
+    endDate, // dates changed during DB update
+    order, // preserve order
+    recurrence, // recurrence change during DB update
+    user, // preserve user
+    updatedAt, // changed during DB update
+    ...baseForUpdate // remaining event
+  } = base;
+  return baseForUpdate;
+};
+
 const _generateInstances = (
   rule: string,
   orig: Schema_Event_Core,
diff --git a/packages/backend/src/sync/services/import/sync.import.ts b/packages/backend/src/sync/services/import/sync.import.ts
index 08a23699..46e0bc97 100644
--- a/packages/backend/src/sync/services/import/sync.import.ts
+++ b/packages/backend/src/sync/services/import/sync.import.ts
@@ -3,7 +3,6 @@ import { Origin } from "@core/constants/core.constants";
 import { Logger } from "@core/logger/winston.logger";
 import { MapEvent } from "@core/mappers/map.event";
 import {
-  RecurrenceWithId,
   RecurrenceWithoutId,
   Schema_Event_Recur_Base,
   Schema_Event_Recur_Instance,
@@ -46,6 +45,12 @@ import { organizeGcalEventsByType } from "./sync.import.util";
 
 const logger = Logger("app:sync.import");
 
+export type WithCompassObjectId<T> = Omit<T, "_id"> & { _id: ObjectId };
+
+export type RecurrenceWithObjectId =
+  | WithCompassObjectId<Schema_Event_Recur_Instance>
+  | WithCompassObjectId<Schema_Event_Recur_Base>;
+
 export class SyncImport {
   private gcal: gCalendar;
   // TODO make userid constructor
@@ -79,18 +84,18 @@ export class SyncImport {
    */
   private assignIdsToRecurringEvents(
     events: RecurrenceWithoutId[],
-  ): RecurrenceWithId[] {
+  ): RecurrenceWithObjectId[] {
     const { baseEvent, instances } = categorizeRecurringEvents(events);
 
     const baseId = new ObjectId();
-    const newBase = {
+    const newBase: RecurrenceWithObjectId = {
       ...baseEvent,
-      _id: baseId.toString(),
+      _id: baseId,
     };
 
     const instancesWithIds = instances.map((instance) => ({
       ...instance,
-      _id: new ObjectId().toString(),
+      _id: new ObjectId(),
       recurrence: {
         ...instance.recurrence,
         eventId: baseId.toString(),
@@ -386,7 +391,6 @@ export class SyncImport {
 
     const result = await mongoService.db
       .collection(Collections.EVENT)
-      // @ts-expect-error sending _id as string for ease of testing
       .insertMany(compassSeries);
 
     return result;


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 6797d53b..e7430949 100644
--- a/packages/backend/src/__tests__/helpers/mock.db.queries.ts
+++ b/packages/backend/src/__tests__/helpers/mock.db.queries.ts
@@ -1,6 +1,7 @@
-import { Event_Core, WithCompassId } from "@core/types/event.types";
+import { Event_Core } from "@core/types/event.types";
 import { Collections } from "@backend/common/constants/collections";
 import mongoService from "@backend/common/services/mongo.service";
+import { Event_API } from "@backend/common/types/backend.event.types";
 
 export const getCategorizedEventsInDb = async () => {
   const allEvents = (await getEventsInDb()) as unknown as Event_Core[];
@@ -16,7 +17,7 @@ export const getEventsInDb = async () => {
   return (await mongoService.db
     .collection(Collections.EVENT)
     .find()
-    .toArray()) as unknown as WithCompassId<Event_Core>[];
+    .toArray()) as unknown as Event_API[];
 };
 
 export const isEventCollectionEmpty = async () => {
diff --git a/packages/backend/src/event/services/recur/util/recur.util.test.ts b/packages/backend/src/event/services/recur/util/recur.util.test.ts
new file mode 100644
index 00000000..45437248
--- /dev/null
+++ b/packages/backend/src/event/services/recur/util/recur.util.test.ts
@@ -0,0 +1,23 @@
+import { createMockBaseEvent } from "@core/util/test/ccal.event.factory";
+import { stripBaseProps } from "./recur.util";
+
+describe("stripBaseProps", () => {
+  it("should remove props that are not allowed to change", () => {
+    const baseEvent = createMockBaseEvent();
+    const strippedEvent = stripBaseProps(baseEvent);
+    const prohibitedProps = [
+      "_id",
+      "gEventId",
+      "startDate",
+      "endDate",
+      "order",
+      "recurrence",
+      "user",
+      "updatedAt",
+    ];
+
+    const remainingKeys = Object.keys(strippedEvent);
+    expect(remainingKeys).not.toContain(prohibitedProps);
+    expect(1).toBe(1);
+  });
+});
diff --git a/packages/backend/src/sync/services/import/sync.import.full.test.ts b/packages/backend/src/sync/services/import/sync.import.full.test.ts
index 2fcd8b45..a168aa54 100644
--- a/packages/backend/src/sync/services/import/sync.import.full.test.ts
+++ b/packages/backend/src/sync/services/import/sync.import.full.test.ts
@@ -123,5 +123,10 @@ describe("SyncImport: Full", () => {
     await syncImport.importAllEvents(setup.userId, "test-calendar");
 
     const currentEventsInDb = await mongoService.event.find().toArray();
+    const regularEvents = currentEventsInDb.filter(
+      (e) => e.recurrence === undefined,
+    );
+    expect(regularEvents).toHaveLength(1);
+    expect(regularEvents[0]?.gEventId).toBe("regular-1");
   });
 });
diff --git a/packages/backend/src/sync/services/import/sync.import.series.test.ts b/packages/backend/src/sync/services/import/sync.import.series.test.ts
index 0dd205c5..750b3f07 100644
--- a/packages/backend/src/sync/services/import/sync.import.series.test.ts
+++ b/packages/backend/src/sync/services/import/sync.import.series.test.ts
@@ -1,3 +1,4 @@
+import { ObjectId } from "mongodb";
 import {
   filterBaseEvents,
   filterExistingInstances,
@@ -66,6 +67,10 @@ describe("SyncImport: Series", () => {
 
     const instancesInDb = filterExistingInstances(currentEventsInDb);
     expect(instancesInDb).toHaveLength(expectedInstances);
-    // expect(recurringEvents[0]?.gEventId).toBe(baseRecurringGcalEvent.id);
+
+    // validate ids
+    currentEventsInDb.forEach((event) => {
+      expect(event._id).toBeInstanceOf(ObjectId);
+    });
   });
 });
diff --git a/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.test.util.ts b/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.test.util.ts
index 649f18f8..0cd3a9c8 100644
--- a/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.test.util.ts
+++ b/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.test.util.ts
@@ -18,6 +18,8 @@ import { TestSetup } from "@backend/__tests__/helpers/mock.db.setup";
 import { State_AfterGcalImport } from "@backend/__tests__/helpers/mock.events.init";
 import { createRecurrenceSeries } from "@backend/__tests__/mocks.db/ccal.mock.db.util";
 import { mockRecurringGcalInstances } from "@backend/__tests__/mocks.gcal/factories/gcal.event.factory";
+import { Event_API } from "@backend/common/types/backend.event.types";
+import { validateEventSafely } from "@backend/common/validators/validate.event";
 
 dayjs.extend(timezone);
 
@@ -69,8 +71,41 @@ export const createCompassSeriesFromGcalBase = async (
   return result;
 };
 
+export const datesAreInUtcOffset = (instance: Schema_Event_Recur_Instance) => {
+  const instanceStart = instance.startDate;
+  const instanceEnd = instance.endDate;
+  expect(typeof instanceStart).toBe("string");
+  expect(typeof instanceEnd).toBe("string");
+
+  // Use dayjs to check parsing and offset
+  const start = dayjs(instanceStart);
+  const end = dayjs(instanceEnd);
+  expect(start.isValid()).toBe(true);
+  expect(end.isValid()).toBe(true);
+
+  // Confirm offset is present (not Z/UTC)
+  expect(instanceStart?.endsWith("Z")).toBe(false);
+  expect(instanceEnd?.endsWith("Z")).toBe(false);
+
+  // Confirm format matches YYYY-MM-DDTHH:mm:ss±HHmm
+  const isoOffsetRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/;
+  expect(instanceStart).toMatch(isoOffsetRegex);
+  expect(instanceEnd).toMatch(isoOffsetRegex);
+
+  // Confirm that the instant in time is correct (string and dayjs agree)
+  expect(start.valueOf()).toBe(dayjs(instanceStart).valueOf());
+  expect(end.valueOf()).toBe(dayjs(instanceEnd).valueOf());
+};
+
+export const eventsMatchSchema = (events: Event_API[]) => {
+  events.forEach((e) => {
+    const result = validateEventSafely(e);
+    expect(result.success).toBe(true);
+  });
+};
+
 export const getLatestEventsFromDb = async () => {
-  const updatedEvents = await getEventsInDb();
+  const updatedEvents = (await getEventsInDb()) as Schema_Event[];
   const { baseEvents, instances } = categorizeEvents(updatedEvents);
   const base = baseEvents[0] as Schema_Event_Recur_Base;
   return { base, instances };
@@ -122,56 +157,6 @@ export const noInstancesAfterSplitDate = async (
   expect(futureInstances).toHaveLength(0);
 };
 
-export const updateBasePayloadToExpireOneDayAfterFirstInstance = (
-  gEvents: State_AfterGcalImport["gcalEvents"],
-) => {
-  if (!gEvents.recurring?.start?.dateTime) {
-    throw new Error("Base event missing start date");
-  }
-  // Get the first instance's start date and add 1 day for the UNTIL date
-  const firstInstanceStart = new Date(
-    gEvents.instances?.[0]?.start?.dateTime as string,
-  );
-  const untilDate = new Date(firstInstanceStart);
-  untilDate.setDate(untilDate.getDate() + 1);
-  const untilDateStr =
-    untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
-
-  const gBaseWithUntil = {
-    ...gEvents.recurring,
-    summary: "Base with UNTIL",
-    recurrence: [`RRULE:FREQ=DAILY;UNTIL=${untilDateStr}`], // this event previously had no UNTIL
-  };
-
-  return { gBaseWithUntil, untilDateStr };
-};
-
-export const datesAreInUtcOffset = (instance: Schema_Event_Recur_Instance) => {
-  const instanceStart = instance.startDate;
-  const instanceEnd = instance.endDate;
-  expect(typeof instanceStart).toBe("string");
-  expect(typeof instanceEnd).toBe("string");
-
-  // Use dayjs to check parsing and offset
-  const start = dayjs(instanceStart);
-  const end = dayjs(instanceEnd);
-  expect(start.isValid()).toBe(true);
-  expect(end.isValid()).toBe(true);
-
-  // Confirm offset is present (not Z/UTC)
-  expect(instanceStart?.endsWith("Z")).toBe(false);
-  expect(instanceEnd?.endsWith("Z")).toBe(false);
-
-  // Confirm format matches YYYY-MM-DDTHH:mm:ss±HHmm
-  const isoOffsetRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/;
-  expect(instanceStart).toMatch(isoOffsetRegex);
-  expect(instanceEnd).toMatch(isoOffsetRegex);
-
-  // Confirm that the instant in time is correct (string and dayjs agree)
-  expect(start.valueOf()).toBe(dayjs(instanceStart).valueOf());
-  expect(end.valueOf()).toBe(dayjs(instanceEnd).valueOf());
-};
-
 export const hasNewUpdatedAtTimestamp = (
   newInstance: Schema_Event_Recur_Instance,
   oldInstances: Schema_Event_Recur_Instance[],
@@ -203,6 +188,30 @@ export const instanceDataMatchCompassBase = (
   expect(cInstance.recurrence?.eventId).toEqual(cBaseId); // still points to base
 };
 
+export const updateBasePayloadToExpireOneDayAfterFirstInstance = (
+  gEvents: State_AfterGcalImport["gcalEvents"],
+) => {
+  if (!gEvents.recurring?.start?.dateTime) {
+    throw new Error("Base event missing start date");
+  }
+  // Get the first instance's start date and add 1 day for the UNTIL date
+  const firstInstanceStart = new Date(
+    gEvents.instances?.[0]?.start?.dateTime as string,
+  );
+  const untilDate = new Date(firstInstanceStart);
+  untilDate.setDate(untilDate.getDate() + 1);
+  const untilDateStr =
+    untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
+
+  const gBaseWithUntil = {
+    ...gEvents.recurring,
+    summary: "Base with UNTIL",
+    recurrence: [`RRULE:FREQ=DAILY;UNTIL=${untilDateStr}`], // this event previously had no UNTIL
+  };
+
+  return { gBaseWithUntil, untilDateStr };
+};
+
 const _getCompassDays = (e: Schema_Event) => {
   const start = dayjs.tz(e.startDate as string, "UTC");
   const end = dayjs.tz(e.endDate as string, "UTC");
diff --git a/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.base.test.ts b/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.base.test.ts
index 6494c8b6..840d8c2d 100644
--- a/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.base.test.ts
+++ b/packages/backend/src/sync/services/sync/__tests__/gcal.sync.processor.upsert.base.test.ts
@@ -14,6 +14,7 @@ import { RecurringEventRepository } from "@backend/event/services/recur/repo/rec
 import { GcalSyncProcessor } from "../gcal.sync.processor";
 import {
   datesAreInUtcOffset,
+  eventsMatchSchema,
   getLatestEventsFromDb,
   hasNewUpdatedAtTimestamp,
   instanceDataMatchCompassBase,
@@ -59,6 +60,8 @@ describe("GcalSyncProcessor UPSERT: BASE", () => {
       category: Categories_Recurrence.RECURRENCE_BASE,
       operation: "UPSERTED",
     });
+    const updatedEvents = await getEventsInDb();
+    eventsMatchSchema(updatedEvents);
   });
 
   it("should handle UPDATING an ALL-DAY SERIES", async () => {