Skip to content
Merged
39 changes: 38 additions & 1 deletion packages/backend/src/__tests__/drivers/util.driver.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { WithId } from "mongodb";
import { ObjectId, WithId } from "mongodb";
import { faker } from "@faker-js/faker";
import { Schema_Sync } from "@core/types/sync.types";
import { Schema_User } from "@core/types/user.types";
import { SyncDriver } from "@backend/__tests__/drivers/sync.driver";
import { UserDriver } from "@backend/__tests__/drivers/user.driver";
import { WaitListDriver } from "@backend/__tests__/drivers/waitlist.driver";
import mongoService from "@backend/common/services/mongo.service";

export class UtilDriver {
static async setupTestUser(): Promise<{ user: WithId<Schema_User> }> {
Expand All @@ -17,4 +20,38 @@ export class UtilDriver {

return { user };
}

static async generateV0SyncData(
numUsers = 3,
): Promise<Array<WithId<Omit<Schema_Sync, "_id">>>> {
const users = await Promise.all(
Array.from({ length: numUsers }, UserDriver.createUser),
);

const data = users.map((user) => ({
_id: new ObjectId(),
user: user._id.toString(),
google: {
events: [
{
resourceId: faker.string.ulid(),
gCalendarId: user.email,
lastSyncedAt: faker.date.past(),
nextSyncToken: faker.string.alphanumeric(32),
channelId: faker.string.uuid(),
expiration: faker.date.future().getTime().toString(),
},
],
calendarlist: [
{
nextSyncToken: faker.string.alphanumeric(32),
gCalendarId: user.email,
lastSyncedAt: faker.date.past(),
},
],
},
}));

return mongoService.sync.insertMany(data).then(() => data);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/common/constants/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export const Collections = {
SYNC: IS_DEV ? "_dev.sync" : "sync",
USER: IS_DEV ? "_dev.user" : "user",
WAITLIST: IS_DEV ? "_dev.waitlist" : "waitlist",
WATCH: IS_DEV ? "_dev.watch" : "watch",
};
24 changes: 18 additions & 6 deletions packages/backend/src/common/services/mongo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import {
} from "mongodb";
import { Logger } from "@core/logger/winston.logger";
import {
CompassCalendar,
Schema_CalendarList as Schema_Calendar,
Schema_CalendarList as Schema_CalList,
Schema_Calendar,
} from "@core/types/calendar.types";
import { Schema_Event } from "@core/types/event.types";
import { Schema_Sync } from "@core/types/sync.types";
import { Schema_User } from "@core/types/user.types";
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
import { Schema_Watch } from "@core/types/watch.types";
import { Collections } from "@backend/common/constants/collections";
import { ENV } from "@backend/common/constants/env.constants";
import { waitUntilEvent } from "@backend/common/helpers/common.util";
Expand All @@ -27,12 +28,13 @@ const logger = Logger("app:mongo.service");
interface InternalClient {
db: Db;
client: MongoClient;
calendar: Collection<CompassCalendar>;
calendarList: Collection<Schema_Calendar>;
calendar: Collection<Schema_Calendar>;
calendarList: Collection<Schema_CalList>;
event: Collection<Omit<Schema_Event, "_id">>;
sync: Collection<Schema_Sync>;
user: Collection<Schema_User>;
waitlist: Collection<Schema_Waitlist>;
watch: Collection<Omit<Schema_Watch, "_id">>;
}

class MongoService {
Expand Down Expand Up @@ -96,6 +98,15 @@ class MongoService {
return this.#accessInternalCollectionProps("waitlist");
}

/**
* watch
*
* mongo collection
*/
get watch(): InternalClient["watch"] {
return this.#accessInternalCollectionProps("watch");
}

private onConnect(client: MongoClient, useDynamicDb = false) {
this.#internalClient = this.createInternalClient(client, useDynamicDb);

Expand Down Expand Up @@ -127,12 +138,13 @@ class MongoService {
return {
db,
client,
calendar: db.collection<CompassCalendar>(Collections.CALENDAR),
calendarList: db.collection<Schema_Calendar>(Collections.CALENDARLIST),
calendar: db.collection<Schema_Calendar>(Collections.CALENDAR),
calendarList: db.collection<Schema_CalList>(Collections.CALENDARLIST),
event: db.collection<Omit<Schema_Event, "_id">>(Collections.EVENT),
sync: db.collection<Schema_Sync>(Collections.SYNC),
user: db.collection<Schema_User>(Collections.USER),
waitlist: db.collection<Schema_Waitlist>(Collections.WAITLIST),
watch: db.collection<Omit<Schema_Watch, "_id">>(Collections.WATCH),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/types/calendar.types.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ObjectId } from "bson";
import { faker } from "@faker-js/faker";
import {
CompassCalendarSchema,
Expand Down Expand Up @@ -65,7 +66,7 @@ describe("Calendar Types", () => {

describe("CompassCalendarSchema", () => {
const compassCalendar = {
_id: faker.database.mongodbObjectId(),
_id: new ObjectId(),
user: faker.database.mongodbObjectId(),
backgroundColor: gCalendar.backgroundColor!,
color: gCalendar.foregroundColor!,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/types/calendar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
IDSchemaV4,
RGBHexSchema,
TimezoneSchema,
zObjectId,
} from "@core/types/type.utils";

// @deprecated - will be replaced by Schema_Calendar
Expand Down Expand Up @@ -55,7 +56,7 @@ export const GoogleCalendarMetadataSchema = z.object({
});

export const CompassCalendarSchema = z.object({
_id: IDSchemaV4,
_id: zObjectId,
user: IDSchemaV4,
backgroundColor: RGBHexSchema,
color: RGBHexSchema,
Expand All @@ -67,4 +68,4 @@ export const CompassCalendarSchema = z.object({
metadata: GoogleCalendarMetadataSchema, // use union when other providers present
});

export type CompassCalendar = z.infer<typeof CompassCalendarSchema>;
export type Schema_Calendar = z.infer<typeof CompassCalendarSchema>;
11 changes: 11 additions & 0 deletions packages/core/src/types/type.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ObjectId } from "bson";
import { z } from "zod";
import { z as zod4 } from "zod/v4";
import { z as zod4Mini } from "zod/v4-mini";

export type KeyOfType<T, V> = keyof {
[P in keyof T as T[P] extends V ? P : never]: unknown;
Expand All @@ -19,6 +20,16 @@ export const IDSchemaV4 = zod4.string().refine(ObjectId.isValid, {
message: "Invalid id",
});

export const zObjectIdMini = zod4Mini.pipe(
zod4Mini.custom<ObjectId | string>(ObjectId.isValid),
zod4Mini.transform((v) => new ObjectId(v)),
);

export const zObjectId = zod4.pipe(
zod4.custom<ObjectId | string>((v) => ObjectId.isValid(v as string)),
zod4.transform((v) => new ObjectId(v)),
);

export const TimezoneSchema = zod4.string().refine(
(timeZone) => {
try {
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/types/watch.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ObjectId } from "bson";
import { faker } from "@faker-js/faker";
import { Schema_Watch, WatchSchema } from "@core/types/watch.types";

describe("Watch Types", () => {
const validWatch: Schema_Watch = {
_id: new ObjectId(),
user: faker.database.mongodbObjectId(),
resourceId: faker.string.alphanumeric(20),
expiration: faker.date.future(),
createdAt: new Date(),
};

describe("WatchSchema", () => {
it("parses valid watch data", () => {
expect(() => WatchSchema.parse(validWatch)).not.toThrow();
});

it("defaults createdAt to current date when not provided", () => {
const watchWithoutCreatedAt = {
...validWatch,
createdAt: undefined,
};

const parsed = WatchSchema.parse(watchWithoutCreatedAt);
expect(parsed.createdAt).toBeInstanceOf(Date);
});

it("accepts valid MongoDB ObjectId for user", () => {
const watchData = {
...validWatch,
user: faker.database.mongodbObjectId(),
};

expect(() => WatchSchema.parse(watchData)).not.toThrow();
});

it("rejects invalid MongoDB ObjectId for user", () => {
const watchData = {
...validWatch,
user: "invalid-object-id",
};

expect(() => WatchSchema.parse(watchData)).toThrow();
});

it("requires all mandatory fields", () => {
const requiredFields = ["_id", "user", "resourceId", "expiration"];

requiredFields.forEach((field) => {
const incompleteWatch = { ...validWatch };
delete incompleteWatch[field as keyof Schema_Watch];

expect(() => WatchSchema.parse(incompleteWatch)).toThrow();
});
});

it("requires expiration to be a Date", () => {
const watchData = {
...validWatch,
expiration: "2024-12-31T23:59:59Z", // string instead of Date
};

expect(() => WatchSchema.parse(watchData)).toThrow();
});
});
});
22 changes: 22 additions & 0 deletions packages/core/src/types/watch.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { z } from "zod/v4";
import { IDSchemaV4, zObjectId } from "@core/types/type.utils";

/**
* Watch collection schema for Google Calendar push notification channels
*
* This schema stores channel metadata for Google Calendar push notifications
* to enable reliable lifecycle management of channels (creation, renewal,
* expiration, deletion) separately from sync data.
*/
export const WatchSchema = z.object({
_id: zObjectId, // channel_id - unique identifier for the notification channel
user: IDSchemaV4, // user who owns this watch channel
resourceId: z.string(), // Google Calendar resource identifier
expiration: z.date(), // when the channel expires
createdAt: z
.date()
.optional()
.default(() => new Date()), // when this watch was created
});

export type Schema_Watch = z.infer<typeof WatchSchema>;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ObjectId } from "bson";
import { faker } from "@faker-js/faker";
import { zodToMongoSchema } from "@scripts/common/zod-to-mongo-schema";
import Migration from "@scripts/migrations/2025.10.03T01.19.59.calendar-schema";
Expand Down Expand Up @@ -121,7 +122,7 @@ describe("2025.10.03T01.19.59.calendar-schema", () => {
const gCalendar = GoogleCalendarMetadataSchema.parse(gCalendarEntry);

return CompassCalendarSchema.parse({
_id: faker.database.mongodbObjectId(),
_id: new ObjectId(),
user: faker.database.mongodbObjectId(),
backgroundColor: gCalendarEntry.backgroundColor!,
color: gCalendarEntry.foregroundColor!,
Expand Down
Loading