Skip to content

Commit 327ec32

Browse files
Copilotvictor-enogweCopilot
committed
:sparkles feat(sub-calendars): add watch collection for gcal resources(#1101)
* Initial plan * Add watch collection schema, types, mongo service integration, and create collection migration Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * Add events watch data migration with comprehensive tests and error handling Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * ✨ feat(sub-calendars): update watch schema and tests * :sparkles feat(sub-calendars): update watch collection schema and tests * Update packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 534ef91 commit 327ec32

15 files changed

Lines changed: 580 additions & 29 deletions

packages/backend/src/__tests__/drivers/util.driver.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { WithId } from "mongodb";
1+
import { ObjectId, WithId } from "mongodb";
2+
import { faker } from "@faker-js/faker";
3+
import { Schema_Sync } from "@core/types/sync.types";
24
import { Schema_User } from "@core/types/user.types";
35
import { SyncDriver } from "@backend/__tests__/drivers/sync.driver";
46
import { UserDriver } from "@backend/__tests__/drivers/user.driver";
57
import { WaitListDriver } from "@backend/__tests__/drivers/waitlist.driver";
8+
import mongoService from "@backend/common/services/mongo.service";
69

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

1821
return { user };
1922
}
23+
24+
static async generateV0SyncData(
25+
numUsers = 3,
26+
): Promise<Array<WithId<Omit<Schema_Sync, "_id">>>> {
27+
const users = await Promise.all(
28+
Array.from({ length: numUsers }, UserDriver.createUser),
29+
);
30+
31+
const data = users.map((user) => ({
32+
_id: new ObjectId(),
33+
user: user._id.toString(),
34+
google: {
35+
events: [
36+
{
37+
resourceId: faker.string.ulid(),
38+
gCalendarId: user.email,
39+
lastSyncedAt: faker.date.past(),
40+
nextSyncToken: faker.string.alphanumeric(32),
41+
channelId: faker.string.uuid(),
42+
expiration: faker.date.future().getTime().toString(),
43+
},
44+
],
45+
calendarlist: [
46+
{
47+
nextSyncToken: faker.string.alphanumeric(32),
48+
gCalendarId: user.email,
49+
lastSyncedAt: faker.date.past(),
50+
},
51+
],
52+
},
53+
}));
54+
55+
return mongoService.sync.insertMany(data).then(() => data);
56+
}
2057
}

packages/backend/src/common/constants/collections.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export const Collections = {
99
SYNC: IS_DEV ? "_dev.sync" : "sync",
1010
USER: IS_DEV ? "_dev.user" : "user",
1111
WAITLIST: IS_DEV ? "_dev.waitlist" : "waitlist",
12+
WATCH: IS_DEV ? "_dev.watch" : "watch",
1213
};

packages/backend/src/common/services/mongo.service.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import {
1111
} from "mongodb";
1212
import { Logger } from "@core/logger/winston.logger";
1313
import {
14-
CompassCalendar,
15-
Schema_CalendarList as Schema_Calendar,
14+
Schema_CalendarList as Schema_CalList,
15+
Schema_Calendar,
1616
} from "@core/types/calendar.types";
1717
import { Schema_Event } from "@core/types/event.types";
1818
import { Schema_Sync } from "@core/types/sync.types";
1919
import { Schema_User } from "@core/types/user.types";
2020
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
21+
import { Schema_Watch } from "@core/types/watch.types";
2122
import { Collections } from "@backend/common/constants/collections";
2223
import { ENV } from "@backend/common/constants/env.constants";
2324
import { waitUntilEvent } from "@backend/common/helpers/common.util";
@@ -27,12 +28,13 @@ const logger = Logger("app:mongo.service");
2728
interface InternalClient {
2829
db: Db;
2930
client: MongoClient;
30-
calendar: Collection<CompassCalendar>;
31-
calendarList: Collection<Schema_Calendar>;
31+
calendar: Collection<Schema_Calendar>;
32+
calendarList: Collection<Schema_CalList>;
3233
event: Collection<Omit<Schema_Event, "_id">>;
3334
sync: Collection<Schema_Sync>;
3435
user: Collection<Schema_User>;
3536
waitlist: Collection<Schema_Waitlist>;
37+
watch: Collection<Omit<Schema_Watch, "_id">>;
3638
}
3739

3840
class MongoService {
@@ -96,6 +98,15 @@ class MongoService {
9698
return this.#accessInternalCollectionProps("waitlist");
9799
}
98100

101+
/**
102+
* watch
103+
*
104+
* mongo collection
105+
*/
106+
get watch(): InternalClient["watch"] {
107+
return this.#accessInternalCollectionProps("watch");
108+
}
109+
99110
private onConnect(client: MongoClient, useDynamicDb = false) {
100111
this.#internalClient = this.createInternalClient(client, useDynamicDb);
101112

@@ -127,12 +138,13 @@ class MongoService {
127138
return {
128139
db,
129140
client,
130-
calendar: db.collection<CompassCalendar>(Collections.CALENDAR),
131-
calendarList: db.collection<Schema_Calendar>(Collections.CALENDARLIST),
141+
calendar: db.collection<Schema_Calendar>(Collections.CALENDAR),
142+
calendarList: db.collection<Schema_CalList>(Collections.CALENDARLIST),
132143
event: db.collection<Omit<Schema_Event, "_id">>(Collections.EVENT),
133144
sync: db.collection<Schema_Sync>(Collections.SYNC),
134145
user: db.collection<Schema_User>(Collections.USER),
135146
waitlist: db.collection<Schema_Waitlist>(Collections.WAITLIST),
147+
watch: db.collection<Omit<Schema_Watch, "_id">>(Collections.WATCH),
136148
};
137149
}
138150

packages/core/src/types/calendar.types.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ObjectId } from "bson";
12
import { faker } from "@faker-js/faker";
23
import {
34
CompassCalendarSchema,
@@ -65,7 +66,7 @@ describe("Calendar Types", () => {
6566

6667
describe("CompassCalendarSchema", () => {
6768
const compassCalendar = {
68-
_id: faker.database.mongodbObjectId(),
69+
_id: new ObjectId(),
6970
user: faker.database.mongodbObjectId(),
7071
backgroundColor: gCalendar.backgroundColor!,
7172
color: gCalendar.foregroundColor!,

packages/core/src/types/calendar.types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
IDSchemaV4,
66
RGBHexSchema,
77
TimezoneSchema,
8+
zObjectId,
89
} from "@core/types/type.utils";
910

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

5758
export const CompassCalendarSchema = z.object({
58-
_id: IDSchemaV4,
59+
_id: zObjectId,
5960
user: IDSchemaV4,
6061
backgroundColor: RGBHexSchema,
6162
color: RGBHexSchema,
@@ -67,4 +68,4 @@ export const CompassCalendarSchema = z.object({
6768
metadata: GoogleCalendarMetadataSchema, // use union when other providers present
6869
});
6970

70-
export type CompassCalendar = z.infer<typeof CompassCalendarSchema>;
71+
export type Schema_Calendar = z.infer<typeof CompassCalendarSchema>;

packages/core/src/types/type.utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ObjectId } from "bson";
22
import { z } from "zod";
33
import { z as zod4 } from "zod/v4";
4+
import { z as zod4Mini } from "zod/v4-mini";
45

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

23+
export const zObjectIdMini = zod4Mini.pipe(
24+
zod4Mini.custom<ObjectId | string>(ObjectId.isValid),
25+
zod4Mini.transform((v) => new ObjectId(v)),
26+
);
27+
28+
export const zObjectId = zod4.pipe(
29+
zod4.custom<ObjectId | string>((v) => ObjectId.isValid(v as string)),
30+
zod4.transform((v) => new ObjectId(v)),
31+
);
32+
2233
export const TimezoneSchema = zod4.string().refine(
2334
(timeZone) => {
2435
try {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ObjectId } from "bson";
2+
import { faker } from "@faker-js/faker";
3+
import { Schema_Watch, WatchSchema } from "@core/types/watch.types";
4+
5+
describe("Watch Types", () => {
6+
const validWatch: Schema_Watch = {
7+
_id: new ObjectId(),
8+
user: faker.database.mongodbObjectId(),
9+
resourceId: faker.string.alphanumeric(20),
10+
expiration: faker.date.future(),
11+
createdAt: new Date(),
12+
};
13+
14+
describe("WatchSchema", () => {
15+
it("parses valid watch data", () => {
16+
expect(() => WatchSchema.parse(validWatch)).not.toThrow();
17+
});
18+
19+
it("defaults createdAt to current date when not provided", () => {
20+
const watchWithoutCreatedAt = {
21+
...validWatch,
22+
createdAt: undefined,
23+
};
24+
25+
const parsed = WatchSchema.parse(watchWithoutCreatedAt);
26+
expect(parsed.createdAt).toBeInstanceOf(Date);
27+
});
28+
29+
it("accepts valid MongoDB ObjectId for user", () => {
30+
const watchData = {
31+
...validWatch,
32+
user: faker.database.mongodbObjectId(),
33+
};
34+
35+
expect(() => WatchSchema.parse(watchData)).not.toThrow();
36+
});
37+
38+
it("rejects invalid MongoDB ObjectId for user", () => {
39+
const watchData = {
40+
...validWatch,
41+
user: "invalid-object-id",
42+
};
43+
44+
expect(() => WatchSchema.parse(watchData)).toThrow();
45+
});
46+
47+
it("requires all mandatory fields", () => {
48+
const requiredFields = ["_id", "user", "resourceId", "expiration"];
49+
50+
requiredFields.forEach((field) => {
51+
const incompleteWatch = { ...validWatch };
52+
delete incompleteWatch[field as keyof Schema_Watch];
53+
54+
expect(() => WatchSchema.parse(incompleteWatch)).toThrow();
55+
});
56+
});
57+
58+
it("requires expiration to be a Date", () => {
59+
const watchData = {
60+
...validWatch,
61+
expiration: "2024-12-31T23:59:59Z", // string instead of Date
62+
};
63+
64+
expect(() => WatchSchema.parse(watchData)).toThrow();
65+
});
66+
});
67+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { z } from "zod/v4";
2+
import { IDSchemaV4, zObjectId } from "@core/types/type.utils";
3+
4+
/**
5+
* Watch collection schema for Google Calendar push notification channels
6+
*
7+
* This schema stores channel metadata for Google Calendar push notifications
8+
* to enable reliable lifecycle management of channels (creation, renewal,
9+
* expiration, deletion) separately from sync data.
10+
*/
11+
export const WatchSchema = z.object({
12+
_id: zObjectId, // channel_id - unique identifier for the notification channel
13+
user: IDSchemaV4, // user who owns this watch channel
14+
resourceId: z.string(), // Google Calendar resource identifier
15+
expiration: z.date(), // when the channel expires
16+
createdAt: z
17+
.date()
18+
.optional()
19+
.default(() => new Date()), // when this watch was created
20+
});
21+
22+
export type Schema_Watch = z.infer<typeof WatchSchema>;

packages/scripts/src/__tests__/integration/2025.10.03T01.19.59.calendar-schema.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ObjectId } from "bson";
12
import { faker } from "@faker-js/faker";
23
import { zodToMongoSchema } from "@scripts/common/zod-to-mongo-schema";
34
import Migration from "@scripts/migrations/2025.10.03T01.19.59.calendar-schema";
@@ -121,7 +122,7 @@ describe("2025.10.03T01.19.59.calendar-schema", () => {
121122
const gCalendar = GoogleCalendarMetadataSchema.parse(gCalendarEntry);
122123

123124
return CompassCalendarSchema.parse({
124-
_id: faker.database.mongodbObjectId(),
125+
_id: new ObjectId(),
125126
user: faker.database.mongodbObjectId(),
126127
backgroundColor: gCalendarEntry.backgroundColor!,
127128
color: gCalendarEntry.foregroundColor!,

0 commit comments

Comments
 (0)