Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/backend/src/common/errors/sync/sync.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ErrorMetadata } from "@backend/common/types/error.types";

interface SyncErrors {
AccessRevoked: ErrorMetadata;
EventWatchExists: ErrorMetadata;
CalendarWatchExists: ErrorMetadata;
NoGCalendarId: ErrorMetadata;
NoResourceId: ErrorMetadata;
Expand All @@ -17,8 +18,13 @@ export const SyncError: SyncErrors = {
status: Status.GONE,
isOperational: true,
},
EventWatchExists: {
description: "Event watch already exists",
status: Status.BAD_REQUEST,
isOperational: true,
},
CalendarWatchExists: {
description: "Watch already exists",
description: "Calendar watch already exists",
status: Status.BAD_REQUEST,
isOperational: true,
},
Expand Down
21 changes: 20 additions & 1 deletion packages/backend/src/common/services/gcal/gcal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,33 @@ class GCalService {
return response.data;
}

watchCalendars = async (
gcal: gCalendar,
params: Omit<Params_WatchEvents, "gCalendarId">,
) => {
const { data } = await gcal.calendarList.watch({
syncToken: params.nextSyncToken,
requestBody: {
// reminder: address always needs to be HTTPS
address: getBaseURL() + GCAL_NOTIFICATION_ENDPOINT,
expiration: params.expiration,
id: `${params.channelId}_calendars`,
token: ENV.TOKEN_GCAL_NOTIFICATION,
type: "web_hook",
},
});

return { watch: data };
};

watchEvents = async (gcal: gCalendar, params: Params_WatchEvents) => {
const { data } = await gcal.events.watch({
calendarId: params.gCalendarId,
requestBody: {
// reminder: address always needs to be HTTPS
address: getBaseURL() + GCAL_NOTIFICATION_ENDPOINT,
expiration: params.expiration,
id: params.channelId,
id: `${params.channelId}_events`,
token: ENV.TOKEN_GCAL_NOTIFICATION,
type: "web_hook",
},
Expand Down
36 changes: 34 additions & 2 deletions packages/backend/src/sync/services/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/g
import {
deleteWatchData,
getSync,
isWatchingCalendars,
isWatchingEventsByGcalId,
updateSync,
} from "@backend/sync/util/sync.queries";
Expand Down Expand Up @@ -317,6 +318,37 @@ class SyncService {
}
};

startWatchingGcalCalendars = async (userId: string, gcal: gCalendar) => {
const alreadyWatching = await isWatchingCalendars(userId);

if (alreadyWatching) {
throw error(SyncError.CalendarWatchExists, "Skipped Start Watch");
}

const channelId = uuidv4();
const expiration = getChannelExpiration();

const watchParams: Omit<Params_WatchEvents, "gCalendarId"> = {
channelId: channelId,
expiration,
};

const { watch } = await gcalService.watchCalendars(gcal, watchParams);
const { resourceId } = watch;

if (!resourceId) {
throw error(SyncError.NoResourceId, "Calendar Watch Failed");
}

const sync = await updateSync(Resource_Sync.CALENDAR, userId, null, {
Copy link

Copilot AI Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing null as the gCalendarId parameter will cause issues in updateSync since it tries to match e.gCalendarId === gCalendarId (line would compare to null) and uses it in the operation. The function expects a string for proper array element matching and updates. Consider modifying updateSync to handle calendar list watches differently, or create a separate update function for this resource type.

Copilot uses AI. Check for mistakes.
channelId,
resourceId,
expiration,
});

return sync;
};

startWatchingGcalEvents = async (
userId: string,
params: { gCalendarId: string },
Expand All @@ -327,7 +359,7 @@ class SyncService {
params.gCalendarId,
);
if (alreadyWatching) {
throw error(SyncError.CalendarWatchExists, "Skipped Start Watch");
throw error(SyncError.EventWatchExists, "Skipped Start Watch");
}

const channelId = uuidv4();
Expand All @@ -342,7 +374,7 @@ class SyncService {
const { resourceId } = watch;

if (!resourceId) {
throw error(SyncError.NoResourceId, "Calendar Watch Failed");
throw error(SyncError.NoResourceId, "Event Watch Failed");
}

const sync = await updateSync(
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/sync/util/sync.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ export const isWatchingEventsByGcalId = async (
return hasSyncFields;
};

export const isWatchingCalendars = async (userId: string) => {
const sync = await mongoService.sync.countDocuments({
user: userId,
"google.calendarlist.$.channelId": { $exists: true },
"google.calendarlist.$.expiration": { $exists: true },
Comment on lines +191 to +192
Copy link

Copilot AI Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The $ positional operator requires a matching array field in the query, but google.calendarlist is not being queried as an array. This will cause the query to fail. Consider using dot notation like 'google.calendarlist.channelId' instead, or if this is an array field, add an array element match condition.

Suggested change
"google.calendarlist.$.channelId": { $exists: true },
"google.calendarlist.$.expiration": { $exists: true },
"google.calendarlist": {
$elemMatch: {
channelId: { $exists: true },
expiration: { $exists: true },
}
}

Copilot uses AI. Check for mistakes.
});

const hasSyncFields = sync === 1;

return hasSyncFields;
};

export const updateSync = async (
resource: Exclude<Resource_Sync, "settings">,
userId: string,
Expand Down