From 0fe8a971a5c30e7a261aec5a88b481022eeb7a63 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Fri, 1 May 2026 15:35:19 -0500 Subject: [PATCH 01/13] refactor(backend): split google sync lifecycle --- docs/backend/README.md | 5 +- docs/development/feature-file-map.md | 8 +- docs/features/google-sync-and-sse-flow.md | 18 +- ...1-google-sync-lifecycle-refactor-report.md | 31 + .../src/__tests__/drivers/sync.driver.ts | 4 +- .../google/google.auth.service.test.ts | 16 +- .../services/google/google.auth.service.ts | 18 +- .../errors/handlers/error.express.handler.ts | 4 +- .../sync/controllers/sync.controller.test.ts | 13 +- .../src/sync/controllers/sync.controller.ts | 17 +- .../sync/controllers/sync.debug.controller.ts | 21 +- .../sync-channel-maintenance.service.test.ts | 39 + .../sync-channel-maintenance.service.ts | 139 +++ .../channel/sync-channel.service.test.ts | 155 +++ .../services/channel/sync-channel.service.ts | 483 ++++++++ .../src/sync/services/import/sync.import.ts | 4 +- .../google-sync-lifecycle.service.test.ts | 145 +++ .../google-sync-lifecycle.service.ts | 292 +++++ .../services/maintain/sync.maintenance.ts | 11 +- .../compass-google-mirror.service.test.ts | 67 ++ .../outbound/compass-google-mirror.service.ts | 94 ++ .../services/records/sync.records.test.ts | 96 ++ .../src/sync/services/records/sync.records.ts | 31 + .../src/sync/services/sync.service.test.ts | 762 ------------- .../backend/src/sync/services/sync.service.ts | 1001 ----------------- .../services/user-metadata.service.test.ts | 6 +- .../src/user/services/user.service.test.ts | 20 +- .../backend/src/user/services/user.service.ts | 17 +- ....10.13T14.22.21.migrate-sync-watch-data.ts | 4 +- .../hooks/actions/draft.movement.test.ts | 61 + .../Draft/hooks/actions/draft.movement.ts | 58 + .../actions/draft.submit-decision.test.ts | 59 + .../hooks/actions/draft.submit-decision.ts | 25 + .../Draft/hooks/actions/submit.parser.test.ts | 2 + .../Draft/hooks/actions/useDraftActions.ts | 88 +- 35 files changed, 1918 insertions(+), 1896 deletions(-) create mode 100644 docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md create mode 100644 packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts create mode 100644 packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts create mode 100644 packages/backend/src/sync/services/channel/sync-channel.service.test.ts create mode 100644 packages/backend/src/sync/services/channel/sync-channel.service.ts create mode 100644 packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts create mode 100644 packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts create mode 100644 packages/backend/src/sync/services/outbound/compass-google-mirror.service.test.ts create mode 100644 packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts create mode 100644 packages/backend/src/sync/services/records/sync.records.test.ts create mode 100644 packages/backend/src/sync/services/records/sync.records.ts delete mode 100644 packages/backend/src/sync/services/sync.service.test.ts delete mode 100644 packages/backend/src/sync/services/sync.service.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts diff --git a/docs/backend/README.md b/docs/backend/README.md index cbf5d62d8..e63f645c8 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -58,13 +58,16 @@ Key files: - `packages/backend/src/event/controllers/event.controller.ts` - `packages/backend/src/sync/services/sync/compass/compass.sync.processor.ts` -- `packages/backend/src/sync/services/sync.service.ts` +- `packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts` ## Google Notification Ingress - endpoint: `POST /api/sync/gcal/notifications` - source: `packages/backend/src/sync/controllers/sync.controller.ts` - middleware: `authMiddleware.verifyIsFromGoogle` +- notification owner: `packages/backend/src/sync/services/channel/sync-channel.service.ts` +- import/repair owner: `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- maintenance owner: `packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts` Observed outcomes include: diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index fdc49f5f6..5ab3c6b0d 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -81,7 +81,13 @@ Use this document to find the first files to inspect for common Compass changes. - Shared SSE event names: `packages/core/src/constants/sse.constants.ts` - Backend SSE server: `packages/backend/src/servers/sse/sse.server.ts` - Events stream route: `packages/backend/src/events/events.routes.config.ts` -- Backend sync routes/services: `packages/backend/src/sync/sync.routes.config.ts`, `packages/backend/src/sync/services` +- Backend sync routes: `packages/backend/src/sync/sync.routes.config.ts` +- Sync Channel lifecycle and notifications: `packages/backend/src/sync/services/channel` +- Google import and repair lifecycle: `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- Compass-to-Google repair mirroring: `packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts` +- Sync record persistence: `packages/backend/src/sync/services/records/sync.records.ts` +- Google import internals: `packages/backend/src/sync/services/import` +- Google/Compass event processors: `packages/backend/src/sync/services/sync` ## Users / Metadata / Priority diff --git a/docs/features/google-sync-and-sse-flow.md b/docs/features/google-sync-and-sse-flow.md index 8ca8066fa..c40581899 100644 --- a/docs/features/google-sync-and-sse-flow.md +++ b/docs/features/google-sync-and-sse-flow.md @@ -23,7 +23,7 @@ flowchart LR subgraph Backend["packages/backend"] Stream[events.controller stream] Srv[sse.server] - Sync[sync.service] + Sync[Google sync modules] Err[error handler] end ES -->|GET /api/events/stream| Stream @@ -78,7 +78,7 @@ Source: - `packages/core/src/types/sse.types.ts` - `packages/backend/src/user/services/user.service.ts` -- `packages/backend/src/sync/services/sync.service.ts` +- `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` `IMPORT_GCAL_END` carries an explicit `operation` so the client can distinguish repair completion from incremental completion. @@ -130,7 +130,7 @@ High-level path: 1. Google posts to the notification endpoint in sync routes. 2. Backend verifies the request origin. -3. `SyncService.handleGcalNotification()` locates the watch and sync record. +3. `syncChannelService.handleGcalNotification()` locates the watch and sync record. 4. The service builds a Google Calendar client for the user. 5. `GCalNotificationHandler` fetches incremental changes using the stored sync token. 6. `GcalSyncProcessor` applies those changes to Compass data. @@ -139,13 +139,21 @@ High-level path: Primary files: - `packages/backend/src/sync/sync.routes.config.ts` -- `packages/backend/src/sync/services/sync.service.ts` +- `packages/backend/src/sync/services/channel/sync-channel.service.ts` +- `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- `packages/backend/src/sync/services/records/sync.records.ts` - `packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts` - `packages/backend/src/sync/services/sync/google/gcal.sync.processor.ts` +Lifecycle and outbound repair paths live in: + +- `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- `packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts` +- `packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts` + ### Notification Outcomes And Error Semantics -Same as before: recoverable `INITIALIZED` / `IGNORED` / `PROCESSED` paths, `GOOGLE_REVOKED` on invalid refresh token, etc. See inline comments in `SyncService` and `SyncController`. +Same as before: recoverable `INITIALIZED` / `IGNORED` / `PROCESSED` paths, `GOOGLE_REVOKED` on invalid refresh token, etc. See inline comments in `syncChannelService`, `googleSyncLifecycleService`, and `SyncController`. ## SSE Server Responsibilities diff --git a/docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md b/docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md new file mode 100644 index 000000000..e7d53466b --- /dev/null +++ b/docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md @@ -0,0 +1,31 @@ +# Google Sync Lifecycle Refactor Report + +## What Changed + +Google sync is now organized around the actual product flows: Sync Channels, +Google sync lifecycle, Google event import/mirroring, and Draft Event +interaction. + +The old all-purpose sync service was removed. Callers now use the module that +owns the behaviour they need. + +## What Was Verified + +- `bun run test:core` +- `bun run test:backend` +- `bun run test:web` +- `bun run type-check` +- `bun run lint` + +## Behaviour Notes + +The refactor is intended to preserve existing user-visible behaviour. The main +improvement is that Google sync errors, repairs, watch notifications, and draft +editing decisions are easier to understand and test. + +## Remaining Risk + +Google Calendar behaviour still depends on external Google APIs and public +webhook delivery. Local tests cover Compass decisions and recovery paths, but +production webhook delivery should still be checked before relying on continuous +sync. diff --git a/packages/backend/src/__tests__/drivers/sync.driver.ts b/packages/backend/src/__tests__/drivers/sync.driver.ts index b7d1f9500..605636c03 100644 --- a/packages/backend/src/__tests__/drivers/sync.driver.ts +++ b/packages/backend/src/__tests__/drivers/sync.driver.ts @@ -6,7 +6,7 @@ import dayjs from "@core/util/date/dayjs"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import mongoService from "@backend/common/services/mongo.service"; -import syncService from "@backend/sync/services/sync.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; import { updateSync } from "@backend/sync/util/sync.queries"; export class SyncDriver { @@ -24,7 +24,7 @@ export class SyncDriver { await updateSync(Resource_Sync.EVENTS, user._id.toString(), gCalendarId, { nextSyncToken: faker.string.ulid(), }); - await syncService.startWatchingGcalResources( + await syncChannelService.startWatchingGcalResources( user._id.toString(), [{ gCalendarId }, { gCalendarId: Resource_Sync.CALENDAR }], // Watch all selected calendars and calendar list gcal, diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index f060cb31d..bd597ccaf 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -12,7 +12,7 @@ import { determineGoogleAuthMode } from "@backend/auth/services/google/util/goog import { AuthError } from "@backend/common/errors/auth/auth.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; -import syncService from "@backend/sync/services/sync.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import googleAuthService from "./google.auth.service"; @@ -220,7 +220,7 @@ describe("GoogleAuthService", () => { refresh_token: faker.string.uuid(), }; const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); await userService.pruneGoogleData(compassUserId); @@ -263,7 +263,7 @@ describe("GoogleAuthService", () => { }; const restartError = new Error("sync failed"); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockRejectedValue(restartError); await userService.pruneGoogleData(compassUserId); @@ -300,7 +300,7 @@ describe("GoogleAuthService", () => { }); const refreshToken = faker.string.uuid(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -345,7 +345,7 @@ describe("GoogleAuthService", () => { withGoogle: false, }); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -384,7 +384,7 @@ describe("GoogleAuthService", () => { it("rejects when the Google account email does not match the current Compass user", async () => { const user = await UserDriver.createUser({ withGoogle: false }); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -434,7 +434,7 @@ describe("GoogleAuthService", () => { .spyOn(EmailService, "tagNewUserIfEnabled") .mockResolvedValue(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const result = await googleAuthService.googleSignup( @@ -467,7 +467,7 @@ describe("GoogleAuthService", () => { } as TokenPayload; const refreshToken = faker.string.uuid(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const tagNewUserSpy = jest .spyOn(EmailService, "tagNewUserIfEnabled") diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index d96b38d30..fffb54d4c 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -14,7 +14,7 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { normalizeEmail } from "@backend/common/helpers/email.util"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; -import syncService from "@backend/sync/services/sync.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -47,12 +47,14 @@ class GoogleAuthService { }; private restartGoogleCalendarSyncInBackground = (cUserId: string) => { - syncService.restartGoogleCalendarSync(cUserId).catch((err) => { - logger.error( - `Something went wrong with starting calendar sync for user ${cUserId}`, - err, - ); - }); + googleSyncLifecycleService + .restartGoogleCalendarSync(cUserId) + .catch((err) => { + logger.error( + `Something went wrong with starting calendar sync for user ${cUserId}`, + err, + ); + }); }; async googleSignup( @@ -189,7 +191,7 @@ class GoogleAuthService { const googleOAuthClient = new GoogleOAuthClient(); googleOAuthClient.oauthClient.setCredentials(oAuthTokens); - syncService + googleSyncLifecycleService .importIncremental(cUserId, googleOAuthClient.getGcalClient()) .catch(async (err) => { if ( diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index 815fa57ac..1e7c4359f 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -24,7 +24,7 @@ import { } from "@backend/common/types/error.types"; import { type SessionResponse } from "@backend/common/types/express.types"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncService from "@backend/sync/services/sync.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { getSyncByToken } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; @@ -131,7 +131,7 @@ const handleGoogleError = async ( } if (isFullSyncRequired(e)) { - syncService + googleSyncLifecycleService .restartGoogleCalendarSync(userId, { force: true }) .catch((err) => { logger.error( diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index b01d22992..f94b7754a 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -30,8 +30,9 @@ import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/e import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; -import syncService from "@backend/sync/services/sync.service"; import * as syncQueries from "@backend/sync/util/sync.queries"; import { updateSync } from "@backend/sync/util/sync.queries"; import userService from "@backend/user/services/user.service"; @@ -139,7 +140,7 @@ describe("SyncController", () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const watch = await mongoService.watch.findOne({ @@ -181,7 +182,7 @@ describe("SyncController", () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockImplementation(async () => { await userMetadataService.updateUserMetadata({ userId, @@ -329,7 +330,7 @@ describe("SyncController", () => { expect(watch).not.toBeNull(); const handleGcalNotificationSpy = jest - .spyOn(syncService, "handleGcalNotification") + .spyOn(syncChannelService, "handleGcalNotification") .mockRejectedValue(invalidGrant400Error); const pruneGoogleDataSpy = jest @@ -377,7 +378,7 @@ describe("SyncController", () => { expect(watch).not.toBeNull(); const handleGcalNotificationSpy = jest - .spyOn(syncService, "handleGcalNotification") + .spyOn(syncChannelService, "handleGcalNotification") .mockRejectedValue(missingRefreshTokenError); const pruneGoogleDataSpy = jest @@ -414,7 +415,7 @@ describe("SyncController", () => { it("should return GONE status when missing refresh token and no watch record found", async () => { const handleGcalNotificationSpy = jest - .spyOn(syncService, "handleGcalNotification") + .spyOn(syncChannelService, "handleGcalNotification") .mockRejectedValue(missingRefreshTokenError); const response = await syncDriver.handleGoogleNotification( diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 03476a79e..6552a057f 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -20,7 +20,9 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncService from "@backend/sync/services/sync.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import syncChannelMaintenanceService from "@backend/sync/services/channel/sync-channel-maintenance.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { getSync } from "@backend/sync/util/sync.queries"; import { isMissingGoogleRefreshToken } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; @@ -77,7 +79,7 @@ export class SyncController { userId: string, ): void => { // do not await this call - syncService + googleSyncLifecycleService .restartGoogleCalendarSync(userId, { force: true }) .catch((err) => { logger.error( @@ -132,7 +134,7 @@ export class SyncController { // When Google returns 410 (sync token invalid), the token may still exist // in the database but is no longer valid. assessGoogleMetadata checks token // existence, not validity, so we must force-restart directly. - syncService + googleSyncLifecycleService .restartGoogleCalendarSync(userId, { force: true }) .catch((err) => { logger.error( @@ -202,7 +204,8 @@ export class SyncController { ), }); - const response = await syncService.handleGcalNotification(syncPayload); + const response = + await syncChannelService.handleGcalNotification(syncPayload); res.promise(response); } catch (e) { @@ -259,7 +262,7 @@ export class SyncController { try { // To avoid 504 timeouts on this long running endpoint // to support the reliance of the google cloud function - // on the result of the syncService.runMaintenance call for notifications + // on the result of the sync maintenance call for notifications // we run the underlying sync logic for each user in parallel // to speed it up. If some of the syncs fail, investigate // Google API quota limits first. @@ -273,7 +276,7 @@ export class SyncController { }); }); // 5 minutes timeout - const result = await syncService.runMaintenance(); + const result = await syncChannelMaintenanceService.runMaintenance(); if (!res.headersSent) res.promise(result); } catch (e) { @@ -287,7 +290,7 @@ export class SyncController { const { force } = ImportGCalRequestSchema.parse(req.body); const isForce = force === true; - syncService + googleSyncLifecycleService .restartGoogleCalendarSync(userId, { force: isForce }) .catch((err) => { logger.error( diff --git a/packages/backend/src/sync/controllers/sync.debug.controller.ts b/packages/backend/src/sync/controllers/sync.debug.controller.ts index 62c21d622..8c3b767e3 100644 --- a/packages/backend/src/sync/controllers/sync.debug.controller.ts +++ b/packages/backend/src/sync/controllers/sync.debug.controller.ts @@ -7,7 +7,9 @@ import { type SReqBody, } from "@backend/common/types/express.types"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncService from "../services/sync.service"; +import syncChannelService from "../services/channel/sync-channel.service"; +import syncChannelMaintenanceService from "../services/channel/sync-channel-maintenance.service"; +import googleSyncLifecycleService from "../services/lifecycle/google-sync-lifecycle.service"; import { getSync } from "../util/sync.queries"; class SyncDebugController { @@ -35,7 +37,7 @@ class SyncDebugController { res.promise(Promise.reject({ error: "no userId param" })); return; } - const result = await syncService.importIncremental(userId); + const result = await googleSyncLifecycleService.importIncremental(userId); res.promise(result); }; @@ -54,9 +56,12 @@ class SyncDebugController { return; } - const result = await syncService.runMaintenanceByUser(userId, { - dry, - }); + const result = await syncChannelMaintenanceService.runMaintenanceByUser( + userId, + { + dry, + }, + ); res.promise(result); } catch (e) { @@ -90,7 +95,7 @@ class SyncDebugController { const calendarId = req.body.calendarId; const gcal = await getGcalClient(userId); - const watchResult = await syncService.startWatchingGcalEvents( + const watchResult = await syncChannelService.startWatchingGcalEvents( userId, { gCalendarId: calendarId, @@ -113,7 +118,7 @@ class SyncDebugController { userId = req.session?.getUserId() as string; } - const stopResult = await syncService.stopWatches(userId); + const stopResult = await syncChannelService.stopWatches(userId); res.promise(stopResult); } catch (e) { const _e = e as BaseError; @@ -130,7 +135,7 @@ class SyncDebugController { const channelId = req.body.channelId; const resourceId = req.body.resourceId; - const stopResult = await syncService.stopWatch( + const stopResult = await syncChannelService.stopWatch( userId, channelId, resourceId, diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts new file mode 100644 index 000000000..01c7397a8 --- /dev/null +++ b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts @@ -0,0 +1,39 @@ +import { ObjectId } from "mongodb"; +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import mongoService from "@backend/common/services/mongo.service"; +import syncChannelMaintenanceService from "@backend/sync/services/channel/sync-channel-maintenance.service"; + +describe("syncChannelMaintenanceService", () => { + beforeAll(initSupertokens); + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterAll(cleanupTestDb); + + it("returns maintenance buckets in dry mode without mutating watches", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const watch = { + _id: new ObjectId(), + user: userId, + resourceId: "resource-id", + expiration: new Date(Date.now() - 60_000), + gCalendarId: "primary", + createdAt: new Date(), + }; + await mongoService.watch.insertOne(watch); + + const result = await syncChannelMaintenanceService.runMaintenanceByUser( + userId, + { dry: true }, + ); + + expect(result.prune[0].payload).toEqual([watch]); + expect(await mongoService.watch.countDocuments({ user: userId })).toBe(1); + }); +}); diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts new file mode 100644 index 000000000..1f114dd04 --- /dev/null +++ b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts @@ -0,0 +1,139 @@ +import { type ObjectId } from "mongodb"; +import { Logger } from "@core/logger/winston.logger"; +import { MONGO_BATCH_SIZE } from "@backend/common/constants/backend.constants"; +import mongoService from "@backend/common/services/mongo.service"; +import { + prepWatchMaintenanceForUser, + pruneSync, + refreshWatch, +} from "@backend/sync/services/maintain/sync.maintenance"; +import { createConcurrencyLimiter } from "@backend/sync/util/sync.util"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +const logger = Logger("app:sync-channel-maintenance.service"); + +class SyncChannelMaintenanceService { + runMaintenance = async () => { + const cursor = mongoService.user.find().batchSize(MONGO_BATCH_SIZE); + const users: ObjectId[] = []; + const result = { + deleted: 0, + refreshed: 0, + ignored: 0, + pruned: 0, + revoked: 0, + resynced: 0, + }; + + for await (const user of cursor) { + users.push(user._id); + } + + const limit = createConcurrencyLimiter(5); + + const run = await Promise.all( + users.map((user) => + limit(() => + this.runMaintenanceByUser(user.toString(), { + log: false, + }).catch((error) => { + logger.error( + `Error running sync maintenance for user: ${user.toString()}`, + error, + ); + + return { + ignore: [{ user: user.toString(), payload: [] }], + prune: [{ user: user.toString(), payload: [] }], + refresh: [{ user: user.toString(), payload: [] }], + ...result, + }; + }), + ), + ), + ); + + const results = run.reduce( + (acc, res) => ({ + deleted: acc.deleted + res.deleted, + refreshed: acc.refreshed + res.refreshed, + ignored: acc.ignored + res.ignored, + pruned: acc.pruned + res.pruned, + revoked: acc.revoked + res.revoked, + resynced: acc.resynced + res.resynced, + }), + result, + ); + + logger.debug(`Sync Maintenance Results: + IGNORED: ${results.ignored} + PRUNED: ${results.pruned} + REFRESHED: ${results.refreshed} + + DELETED DURING PRUNE: ${results.deleted} + REVOKED SESSION DURING REFRESH: ${results.revoked} + RESYNCED DURING REFRESH: ${results.resynced} + `); + + return results; + }; + + runMaintenanceByUser = async ( + userId: string, + params: { dry?: boolean; log?: boolean } = { log: true }, + ) => { + const user = await findCompassUserBy("_id", userId); + const maintenance = await prepWatchMaintenanceForUser(userId); + const ignore = [{ user: userId, payload: maintenance.ignore }]; + const prune = [{ user: userId, payload: maintenance.prune }]; + const refresh = [{ user: userId, payload: maintenance.refresh }]; + + const result = { + ignore, + prune, + refresh, + user: user?.email || "Not found", + ignored: 0, + pruned: 0, + refreshed: 0, + revoked: 0, + deleted: 0, + resynced: 0, + }; + + if (params?.dry) return result; + + const pruneResult = await pruneSync(prune); + const pruned = pruneResult.filter((p) => !p.deletedUserData); + const deletedDuringPrune = pruneResult.filter((p) => p.deletedUserData); + const refreshResult = await refreshWatch(refresh); + const refreshed = refreshResult; + const resynced = refreshResult.filter((r) => r.resynced); + + if (params?.log) { + logger.debug(`Sync Maintenance Results: + IGNORED: ${ignore.length} + PRUNED: ${pruned.flatMap((p) => p.results).length} + REFRESHED: ${refreshed.flatMap((r) => r.results.filter((r) => r.success)).length} + + DELETED DURING PRUNE: ${deletedDuringPrune.map((r) => r.user).toString()} + RESYNCED DURING REFRESH: ${resynced.map((r) => r.user).toString()} + `); + } + + return { + ...result, + ignored: ignore.flatMap(({ payload }) => payload).length, + pruned: pruned.flatMap(({ results }) => results).length, + refreshed: refreshed.flatMap(({ results }) => + results.filter((r) => r.success), + ).length, + deleted: deletedDuringPrune.length, + resynced: resynced.length, + }; + }; +} + +export const syncChannelMaintenanceService = + new SyncChannelMaintenanceService(); +export default syncChannelMaintenanceService; diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.test.ts b/packages/backend/src/sync/services/channel/sync-channel.service.test.ts new file mode 100644 index 000000000..7c17ab109 --- /dev/null +++ b/packages/backend/src/sync/services/channel/sync-channel.service.test.ts @@ -0,0 +1,155 @@ +import { faker } from "@faker-js/faker"; +import { ObjectId } from "mongodb"; +import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; +import { WatchSchema } from "@core/types/watch.types"; +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { createGoogleError } from "@backend/__tests__/mocks.gcal/errors/error.google.factory"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; +import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import gcalService from "@backend/common/services/gcal/gcal.service"; +import mongoService from "@backend/common/services/mongo.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; + +jest.mock("@backend/sync/util/sync.util", () => { + const actual = jest.requireActual("@backend/sync/util/sync.util"); + return { + ...actual, + isUsingGcalWebhookHttps: jest.fn(() => actual.isUsingGcalWebhookHttps()), + }; +}); + +const createWatch = async (user: string) => { + const watch = WatchSchema.parse({ + _id: new ObjectId(), + user, + resourceId: faker.string.uuid(), + expiration: new Date(Date.now() + 60_000), + gCalendarId: faker.string.uuid(), + createdAt: new Date(), + }); + + await mongoService.watch.insertOne(watch); + + return watch; +}; + +describe("syncChannelService", () => { + beforeAll(initSupertokens); + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterEach(() => jest.restoreAllMocks()); + afterAll(cleanupTestDb); + + it("deletes only the target user's watch records and returns their identities", async () => { + const firstUser = await UserDriver.createUser(); + const secondUser = await UserDriver.createUser(); + const firstUserWatch = await createWatch(firstUser._id.toString()); + const secondUserWatch = await createWatch(secondUser._id.toString()); + + const deleted = await syncChannelService.deleteWatchesByUser( + firstUser._id.toString(), + ); + + expect(deleted).toEqual([ + { + channelId: firstUserWatch._id.toString(), + resourceId: firstUserWatch.resourceId, + }, + ]); + expect( + await mongoService.watch.findOne({ _id: firstUserWatch._id }), + ).toBeNull(); + expect( + await mongoService.watch.findOne({ _id: secondUserWatch._id }), + ).toEqual(expect.objectContaining({ user: secondUser._id.toString() })); + }); + + it("deletes the local watch record when Google returns invalid_grant", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue(invalidGrant400Error); + + await expect( + syncChannelService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("rethrows unexpected Google stop errors and keeps the local watch", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "500", responseStatus: 500 }), + ); + + await expect( + syncChannelService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).rejects.toMatchObject({ code: "500" }); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toEqual( + expect.objectContaining({ user: user._id.toString() }), + ); + }); + + it("ignores expired notifications when no local watch record remains", async () => { + const cleanupSpy = jest + .spyOn(syncChannelService, "cleanupStaleWatchChannel") + .mockResolvedValue(false); + + await expect( + syncChannelService.handleGcalNotification({ + resource: Resource_Sync.EVENTS, + channelId: new ObjectId(), + resourceId: faker.string.uuid(), + resourceState: XGoogleResourceState.EXISTS, + expiration: faker.date.future(), + }), + ).resolves.toBe("IGNORED"); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); + + it("skips direct Google watch setup when the Google webhook URL is not HTTPS", async () => { + (isUsingGcalWebhookHttps as jest.Mock).mockReturnValue(false); + const startCalendarWatchSpy = jest.spyOn( + syncChannelService, + "startWatchingGcalCalendars", + ); + const startEventWatchSpy = jest.spyOn( + syncChannelService, + "startWatchingGcalEvents", + ); + + await expect( + syncChannelService.startWatchingGcalResources( + "507f1f77bcf86cd799439011", + [{ gCalendarId: Resource_Sync.CALENDAR }, { gCalendarId: "primary" }], + {} as never, + ), + ).resolves.toEqual([]); + + expect(startCalendarWatchSpy).not.toHaveBeenCalled(); + expect(startEventWatchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.ts b/packages/backend/src/sync/services/channel/sync-channel.service.ts new file mode 100644 index 000000000..212742767 --- /dev/null +++ b/packages/backend/src/sync/services/channel/sync-channel.service.ts @@ -0,0 +1,483 @@ +import { type ClientSession, ObjectId } from "mongodb"; +import { Logger } from "@core/logger/winston.logger"; +import { type gCalendar } from "@core/types/gcal"; +import { + type Params_WatchEvents, + type Payload_Sync_Notif, + Resource_Sync, + type Result_Watch_Stop, + XGoogleResourceState, +} from "@core/types/sync.types"; +import { ExpirationDateSchema } from "@core/types/type.utils"; +import { WatchSchema } from "@core/types/watch.types"; +import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; +import { error } from "@backend/common/errors/handlers/error.handler"; +import { GcalError } from "@backend/common/errors/integration/gcal/gcal.errors"; +import { SyncError } from "@backend/common/errors/sync/sync.errors"; +import { WatchError } from "@backend/common/errors/sync/watch.errors"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import gcalService from "@backend/common/services/gcal/gcal.service"; +import { + getGoogleErrorStatus, + isInvalidGoogleToken, +} from "@backend/common/services/gcal/gcal.utils"; +import mongoService from "@backend/common/services/mongo.service"; +import { sseServer } from "@backend/servers/sse/sse.server"; +import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; +import { + getSync, + isWatchingGoogleResource, +} from "@backend/sync/util/sync.queries"; +import { + getChannelExpiration, + isMissingGoogleRefreshToken, + isUsingGcalWebhookHttps, +} from "@backend/sync/util/sync.util"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +const logger = Logger("app:sync-channel.service"); + +class SyncChannelService { + deleteWatchesByUser = async ( + user: string, + session?: ClientSession, + ): Promise => { + const watches = await mongoService.watch + .find({ user }, { session }) + .toArray(); + + await mongoService.watch.deleteMany({ user }, { session }); + + return watches.map(({ _id, resourceId }) => ({ + channelId: _id.toString(), + resourceId, + })); + }; + + private prepareStopWatches = async ( + user: string, + gcal?: gCalendar, + session?: ClientSession, + ) => { + const watches = await mongoService.watch + .find({ user }, { session }) + .toArray(); + + if (watches.length === 0 || gcal) { + return { watches, gcal }; + } + + const compassUser = await findCompassUserBy("_id", user); + + if (!compassUser) { + throw error(UserError.UserNotFound, "User not found"); + } + + if (!compassUser.google?.gRefreshToken) { + await mongoService.watch.deleteMany({ user }, { session }); + + logger.warn( + "Google refresh token is missing. Corresponding watch records deleted", + ); + + return { watches: [], gcal }; + } + + return { + watches, + gcal: await getGcalClient(user), + }; + }; + + async cleanupStaleWatchChannel({ + channelId, + resourceId, + }: Payload_Sync_Notif): Promise { + const channel = await mongoService.watch.findOne({ + _id: channelId, + resourceId, + }); + + if (!channel) { + logger.warn( + `Ignoring stale Google notification because no exact watch exists for channelId: ${channelId.toString()}, resourceId: ${resourceId}`, + ); + + return false; + } + + try { + await this.stopWatch( + channel.user, + channel._id.toString(), + channel.resourceId, + ); + + logger.warn( + `Cleaned up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()} with resourceId: ${channel.resourceId}`, + ); + + return true; + } catch (error) { + logger.error( + `Failed to clean up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()}`, + error, + ); + + return false; + } + } + + handleGcalNotification = async (payload: Payload_Sync_Notif) => { + const { channelId, resourceId, resourceState, resource } = payload; + const { expiration } = payload; + + if (resourceState === XGoogleResourceState.SYNC) { + logger.info( + `${resource} sync initialized for channelId: ${payload.channelId.toString()}`, + ); + + return "INITIALIZED"; + } + + const watch = await mongoService.watch.findOne({ + _id: channelId, + resourceId, + expiration: { $gte: expiration }, + }); + + if (!watch) { + const cleanedUp = await this.cleanupStaleWatchChannel(payload); + + if (cleanedUp) return "IGNORED"; + + logger.warn( + `Ignoring notification because no active watch record exists for channel: ${payload.channelId.toString()}`, + ); + + return "IGNORED"; + } + + const sync = await getSync({ userId: watch.user, resource }); + + if (!sync) { + const cleanedUp = await this.cleanupStaleWatchChannel(payload); + + if (cleanedUp) return "IGNORED"; + + logger.warn( + `Ignoring notification because no sync record exists for channel: ${payload.channelId.toString()}`, + ); + + return "IGNORED"; + } + + const userId = sync.user; + const { events = [], calendarlist = [] } = sync.google ?? {}; + const channels = [...events, ...calendarlist]; + const channel = channels.find((e) => e.gCalendarId === watch.gCalendarId); + const calendarId = channel?.gCalendarId; + const nextSyncToken = channel?.nextSyncToken; + + if (!nextSyncToken) { + throw error( + SyncError.NoSyncToken, + `Notification not handled because no sync token found for calendarId: ${calendarId}`, + ); + } + + const gcal = await getGcalClient(userId); + const handler = new GCalNotificationHandler( + gcal, + resource, + userId, + watch.gCalendarId, + nextSyncToken, + ); + + await handler.handleNotification(); + + sseServer.handleBackgroundCalendarChange(userId); + + const result = "PROCESSED"; + + logger.info( + `GCal Notification for user: ${userId}, calendarId: ${calendarId} ${result}`, + ); + + return result; + }; + + refreshWatch = async ( + userId: string, + payload: Params_WatchEvents, + gcal?: gCalendar, + ) => { + if (!gcal) gcal = await getGcalClient(userId); + + const watchExists = payload.channelId && payload.resourceId; + + if (watchExists) { + await this.stopWatch(userId, payload.channelId, payload.resourceId, gcal); + } + + const watchResult = await this.startWatchingGcalResources( + userId, + [{ gCalendarId: payload.gCalendarId, quotaUser: payload.quotaUser }], + gcal, + ); + + return watchResult[0]; + }; + + startWatchingGcalCalendars = async ( + user: string, + params: Pick, + gcal: gCalendar, + ): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> => { + try { + const alreadyWatching = await isWatchingGoogleResource( + user, + Resource_Sync.CALENDAR, + ); + + if (alreadyWatching) { + logger.error( + `Skipped Start Watch for ${Resource_Sync.CALENDAR}`, + WatchError.CalendarWatchExists, + ); + + return { acknowledged: false }; + } + + const expiration = getChannelExpiration(); + const _id = new ObjectId(); + const channelId = _id.toString(); + + const { watch: gcalWatch } = await gcalService.watchCalendars(gcal, { + ...params, + channelId, + expiration, + }); + const resourceId = gcalWatch.resourceId; + + if (!resourceId) { + throw error( + GcalError.Unsure, + "Calendar watch response missing resourceId", + ); + } + + const watch = await mongoService.watch + .insertOne( + WatchSchema.parse({ + _id, + user, + gCalendarId: Resource_Sync.CALENDAR, + resourceId, + expiration: ExpirationDateSchema.parse(gcalWatch.expiration), + createdAt: new Date(), + }), + ) + .catch(async (error) => { + await this.stopWatch(user, channelId, resourceId, gcal); + + throw error; + }); + + return watch; + } catch (err) { + logger.error(`Error starting calendar watch for user: ${user}`, err); + + return { acknowledged: false }; + } + }; + + startWatchingGcalEvents = async ( + user: string, + params: Pick, + gcal: gCalendar, + ): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> => { + try { + const alreadyWatching = await isWatchingGoogleResource( + user, + params.gCalendarId, + ); + + if (alreadyWatching) { + logger.error( + `Skipped Start Watch for ${params.gCalendarId} ${Resource_Sync.EVENTS}`, + WatchError.EventWatchExists, + ); + + return { acknowledged: false }; + } + + const expiration = getChannelExpiration(); + const _id = new ObjectId(); + const channelId = _id.toString(); + + const { watch: gcalWatch } = await gcalService.watchEvents(gcal, { + ...params, + channelId, + expiration, + }); + const resourceId = gcalWatch.resourceId; + + if (!resourceId) { + throw error( + GcalError.Unsure, + "Event watch response missing resourceId", + ); + } + + const watch = await mongoService.watch + .insertOne( + WatchSchema.parse({ + _id, + user, + gCalendarId: params.gCalendarId, + resourceId, + expiration: ExpirationDateSchema.parse(gcalWatch.expiration), + createdAt: new Date(), + }), + ) + .catch(async (error) => { + await this.stopWatch(user, channelId, resourceId, gcal); + + throw error; + }); + + return watch; + } catch (err) { + logger.error(`Error starting events watch for user: ${user}`, err); + + return { acknowledged: false }; + } + }; + + startWatchingGcalResources = async ( + userId: string, + watchParams: Pick[], + gcal: gCalendar, + ) => { + if (!isUsingGcalWebhookHttps()) { + return []; + } + + return Promise.all( + watchParams.map(async (params) => { + if (params.gCalendarId === (Resource_Sync.CALENDAR as string)) { + return this.startWatchingGcalCalendars(userId, params, gcal); + } + + return this.startWatchingGcalEvents(userId, params, gcal); + }), + ).then((results) => results.filter((r) => r !== undefined)); + }; + + stopWatch = async ( + user: string, + channelId: string, + resourceId: string, + gcal?: gCalendar, + quotaUser?: string, + session?: ClientSession, + ) => { + const filter = { user, _id: new ObjectId(channelId), resourceId }; + + try { + if (!gcal) gcal = await getGcalClient(user); + + await gcalService.stopWatch(gcal, { + quotaUser, + channelId, + resourceId, + }); + + await mongoService.watch.deleteOne(filter, { session }); + + return { channelId, resourceId }; + } catch (e) { + const status = getGoogleErrorStatus(e); + + if (status === 404) { + await mongoService.watch.deleteOne(filter, { session }); + + logger.warn( + "Channel no longer exists. Corresponding sync record deleted", + ); + + return undefined; + } + + if (status === 401 || isInvalidGoogleToken(e)) { + await mongoService.watch.deleteOne(filter, { session }); + + logger.warn( + "Google authorization is no longer valid. Corresponding sync record deleted", + ); + + return undefined; + } + + if (isMissingGoogleRefreshToken(e)) { + await mongoService.watch.deleteOne(filter, { session }); + + logger.warn( + "Google refresh token is missing. Corresponding watch record deleted", + ); + + return undefined; + } + + throw e; + } + }; + + stopWatches = async ( + user: string, + gcal?: gCalendar, + quotaUser?: string, + session?: ClientSession, + ): Promise => { + const prepared = await this.prepareStopWatches(user, gcal, session); + + if (prepared.watches.length === 0) { + return []; + } + + logger.debug( + `Stopping ${prepared.watches.length} gcal event watches for user: ${user}`, + ); + const result = await Promise.all( + prepared.watches.map(async ({ _id, resourceId }) => + this.stopWatch( + user, + _id.toString(), + resourceId, + prepared.gcal, + quotaUser, + session, + ).catch((error) => { + logger.error( + `Error stopping watch for user: ${user}, channelId: ${_id.toString()}`, + error, + ); + + return undefined; + }), + ), + ); + + const stopped = result.filter( + (identity): identity is { channelId: string; resourceId: string } => + identity !== undefined, + ); + + return stopped; + }; +} + +export const syncChannelService = new SyncChannelService(); +export default syncChannelService; diff --git a/packages/backend/src/sync/services/import/sync.import.ts b/packages/backend/src/sync/services/import/sync.import.ts index 388453aaf..6dac21147 100644 --- a/packages/backend/src/sync/services/import/sync.import.ts +++ b/packages/backend/src/sync/services/import/sync.import.ts @@ -26,10 +26,10 @@ import { SyncError } from "@backend/common/errors/sync/sync.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { getGcalWebhookBaseURL } from "@backend/common/util/api-base-url.util"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; import { type EventsToModify } from "@backend/sync/services/import/sync.import.types"; import { organizeGcalEventsByType } from "@backend/sync/services/import/sync.import.util"; import { getCalendarsToSync } from "@backend/sync/services/init/sync.init"; -import syncService from "@backend/sync/services/sync.service"; import { getGCalEventsSyncPageToken, getSync, @@ -547,7 +547,7 @@ export class SyncImport { undefined, ); - await syncService.startWatchingGcalResources( + await syncChannelService.startWatchingGcalResources( userId, [ ...gCalendarIds.map((gCalendarId) => ({ gCalendarId })), diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts new file mode 100644 index 000000000..5187cab85 --- /dev/null +++ b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts @@ -0,0 +1,145 @@ +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import { sseServer } from "@backend/servers/sse/sse.server"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import * as syncImportService from "@backend/sync/services/import/sync.import"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import userService from "@backend/user/services/user.service"; +import userMetadataService from "@backend/user/services/user-metadata.service"; + +describe("googleSyncLifecycleService", () => { + beforeAll(initSupertokens); + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterEach(() => jest.restoreAllMocks()); + afterAll(cleanupTestDb); + + describe("importIncremental", () => { + it("emits INCREMENTAL operation when incremental import is ignored", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "COMPLETED" } }, + }); + + await googleSyncLifecycleService.importIncremental(userId); + + expect(importEndSpy).toHaveBeenCalledWith(userId, { + operation: "INCREMENTAL", + status: "IGNORED", + message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, + }); + }); + + it("emits INCREMENTAL operation when incremental import completes", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); + + jest.spyOn(syncImportService, "createSyncImport").mockResolvedValue({ + importLatestEvents: jest.fn().mockResolvedValue({}), + } as unknown as Awaited< + ReturnType + >); + + await googleSyncLifecycleService.importIncremental(userId); + + expect(importEndSpy).toHaveBeenCalledWith(userId, { + operation: "INCREMENTAL", + status: "COMPLETED", + }); + }); + }); + + describe("startGoogleCalendarSync", () => { + it("starts Google watches only after full import succeeds", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const callOrder: string[] = []; + const importFull = googleSyncLifecycleService.importFull.bind( + googleSyncLifecycleService, + ); + const startWatching = + syncChannelService.startWatchingGcalResources.bind(syncChannelService); + + jest + .spyOn(googleSyncLifecycleService, "importFull") + .mockImplementation(async (...args) => { + callOrder.push("importFull"); + return importFull(...args); + }); + jest + .spyOn(syncChannelService, "startWatchingGcalResources") + .mockImplementation(async (...args) => { + callOrder.push("startWatching"); + return startWatching(...args); + }); + + await googleSyncLifecycleService.startGoogleCalendarSync(userId); + + expect(callOrder).toEqual(["importFull", "startWatching"]); + }); + }); + + describe("restartGoogleCalendarSync", () => { + it("skips restart when import is completed and not forced", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "COMPLETED" } }, + }); + + const stopSpy = jest.spyOn(userService, "stopGoogleCalendarSync"); + const startSpy = jest.spyOn( + googleSyncLifecycleService, + "startGoogleCalendarSync", + ); + + await googleSyncLifecycleService.restartGoogleCalendarSync(userId); + + expect(stopSpy).not.toHaveBeenCalled(); + expect(startSpy).not.toHaveBeenCalled(); + expect(importEndSpy).toHaveBeenCalledWith(userId, { + operation: "INCREMENTAL", + status: "IGNORED", + message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + }); + }); + + it("forces restart when import is completed", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "COMPLETED" } }, + }); + + const stopSpy = jest + .spyOn(userService, "stopGoogleCalendarSync") + .mockResolvedValue(); + const startSpy = jest + .spyOn(googleSyncLifecycleService, "startGoogleCalendarSync") + .mockResolvedValue({ eventsCount: 0, calendarsCount: 0 }); + + await googleSyncLifecycleService.restartGoogleCalendarSync(userId, { + force: true, + }); + + expect(stopSpy).toHaveBeenCalledWith(userId); + expect(startSpy).toHaveBeenCalledWith(userId); + }); + }); +}); diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts new file mode 100644 index 000000000..0f54bac9f --- /dev/null +++ b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts @@ -0,0 +1,292 @@ +import { Logger } from "@core/logger/winston.logger"; +import { type gCalendar } from "@core/types/gcal"; +import { Resource_Sync } from "@core/types/sync.types"; +import { + shouldDoIncrementalGCalSync, + shouldImportGCal, +} from "@core/util/event/event.util"; +import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; +import calendarService from "@backend/calendar/services/calendar.service"; +import { getGoogleRepairErrorMessage } from "@backend/common/errors/integration/gcal/gcal.errors"; +import { isInvalidGoogleToken } from "@backend/common/services/gcal/gcal.utils"; +import mongoService from "@backend/common/services/mongo.service"; +import { sseServer } from "@backend/servers/sse/sse.server"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { createSyncImport } from "@backend/sync/services/import/sync.import"; +import compassGoogleMirrorService from "@backend/sync/services/outbound/compass-google-mirror.service"; +import { updateSync } from "@backend/sync/util/sync.queries"; +import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; +import userMetadataService from "@backend/user/services/user-metadata.service"; + +const logger = Logger("app:google-sync-lifecycle.service"); + +class GoogleSyncLifecycleService { + private activeFullSyncRestarts = new Set(); + + importFull = async ( + gcal: gCalendar, + gCalendarIds: string[], + userId: string, + ) => { + const session = await mongoService.startSession({ + causalConsistency: true, + }); + + session.startTransaction(); + + try { + const syncImport = await createSyncImport(gcal); + + const eventImports = await Promise.all( + gCalendarIds.map(async (gCalId) => { + const { nextSyncToken, ...result } = await syncImport.importAllEvents( + userId, + gCalId, + 2500, + ); + + await updateSync( + Resource_Sync.EVENTS, + userId, + gCalId, + { nextSyncToken }, + session, + ); + + return { gCalId, ...result }; + }), + ); + + await session.commitTransaction(); + + return eventImports; + } catch (error: unknown) { + await session.abortTransaction(); + + throw error; + } + }; + + importIncremental = async ( + userId: string, + gcal?: gCalendar, + perPage = 1000, + ) => { + logger.info( + `Starting incremental Google Calendar sync for user: ${userId}`, + ); + + try { + sseServer.handleImportGCalStart(userId); + + const userMeta = await userMetadataService.fetchUserMetadata( + userId, + undefined, + { + skipAssessment: true, + }, + ); + const proceed = shouldDoIncrementalGCalSync(userMeta); + + if (!proceed) { + sseServer.handleImportGCalEnd(userId, { + operation: "INCREMENTAL", + status: "IGNORED", + message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, + }); + + return; + } + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "IMPORTING" } }, + }); + + const syncImport = gcal + ? await createSyncImport(gcal) + : await createSyncImport(userId); + + const result = await syncImport.importLatestEvents(userId, perPage); + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "COMPLETED" } }, + }); + + sseServer.handleImportGCalEnd(userId, { + operation: "INCREMENTAL", + status: "COMPLETED", + }); + sseServer.handleBackgroundCalendarChange(userId); + + return result; + } catch (error) { + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "ERRORED" } }, + }); + + logger.error( + `Incremental Google Calendar sync failed for user: ${userId}`, + error, + ); + + sseServer.handleImportGCalEnd(userId, { + operation: "INCREMENTAL", + status: "ERRORED", + message: `Incremental Google Calendar sync failed for user: ${userId}`, + }); + + throw error; + } + }; + + restartGoogleCalendarSync = async ( + userId: string, + options: { force?: boolean } = {}, + ) => { + const { default: userService } = await import( + "@backend/user/services/user.service" + ); + const isForce = options.force === true; + const operation = isForce ? "REPAIR" : "INCREMENTAL"; + const ignoreMessage = `User ${userId} gcal import is in progress or completed, ignoring this request`; + + if (this.activeFullSyncRestarts.has(userId)) { + sseServer.handleImportGCalEnd(userId, { + operation, + status: "IGNORED", + message: ignoreMessage, + }); + return; + } + + this.activeFullSyncRestarts.add(userId); + + try { + const userMeta = await userService.fetchUserMetadata(userId); + const importStatus = userMeta.sync?.importGCal; + const isImporting = importStatus === "IMPORTING"; + const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); + + if (!proceed) { + sseServer.handleImportGCalEnd(userId, { + operation, + status: "IGNORED", + message: ignoreMessage, + }); + + return; + } + + logger.warn( + `Restarting Google Calendar sync for user: ${userId}${isForce ? " (forced)" : ""}`, + ); + sseServer.handleImportGCalStart(userId); + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "IMPORTING" } }, + }); + + await userService.stopGoogleCalendarSync(userId); + const importResults = await this.startGoogleCalendarSync(userId); + + await compassGoogleMirrorService + .syncCompassEventsToGoogle(userId) + .catch((err) => { + logger.error( + `Failed to sync Compass events to Google Calendar for user: ${userId}`, + err, + ); + }); + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "COMPLETED" } }, + }); + + sseServer.handleImportGCalEnd(userId, { + operation, + status: "COMPLETED", + ...importResults, + }); + sseServer.handleBackgroundCalendarChange(userId); + } catch (err) { + try { + await userService.stopGoogleCalendarSync(userId); + } catch (cleanupError) { + logger.error( + `Failed to clean up partial Google Calendar sync state for user: ${userId}`, + cleanupError, + ); + } + + if (isInvalidGoogleToken(err)) { + logger.warn( + `Google Calendar repair failed because access was revoked for user: ${userId}`, + ); + + await userService.pruneGoogleData(userId); + sseServer.handleGoogleRevoked(userId); + return; + } + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "ERRORED" } }, + }); + + logger.error(`Re-sync failed for user: ${userId}`, err); + + sseServer.handleImportGCalEnd(userId, { + operation, + status: "ERRORED", + message: getGoogleRepairErrorMessage(err), + }); + } finally { + this.activeFullSyncRestarts.delete(userId); + } + }; + + startGoogleCalendarSync = async ( + user: string, + ): Promise<{ eventsCount: number; calendarsCount: number }> => { + const gcal = await getGcalClient(user); + + const calendarInit = await calendarService.initializeGoogleCalendars( + user, + gcal, + ); + + const gCalendarIds = calendarInit.googleCalendars + .map(({ id }) => id) + .filter((id): id is string => id !== undefined && id !== null); + + const importResults = await this.importFull(gcal, gCalendarIds, user); + + if (isUsingGcalWebhookHttps()) { + await syncChannelService.startWatchingGcalResources( + user, + [ + { gCalendarId: Resource_Sync.CALENDAR }, + ...gCalendarIds.map((gCalendarId) => ({ gCalendarId })), + ], + gcal, + ); + } + + const eventsCount = importResults.reduce( + (sum, result) => sum + result.totalChanged, + 0, + ); + + return { + eventsCount, + calendarsCount: gCalendarIds.length, + }; + }; +} + +export const googleSyncLifecycleService = new GoogleSyncLifecycleService(); +export default googleSyncLifecycleService; diff --git a/packages/backend/src/sync/services/maintain/sync.maintenance.ts b/packages/backend/src/sync/services/maintain/sync.maintenance.ts index 820ece952..438f65318 100644 --- a/packages/backend/src/sync/services/maintain/sync.maintenance.ts +++ b/packages/backend/src/sync/services/maintain/sync.maintenance.ts @@ -9,7 +9,8 @@ import { isInvalidGoogleToken, } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; -import syncService from "@backend/sync/services/sync.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { hasUpdatedCompassEventRecently } from "@backend/sync/util/sync.queries"; import { syncExpired, syncExpiresSoon } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; @@ -74,7 +75,7 @@ export const pruneSync = async ( try { const results = await Promise.all( payload.map(({ _id, resourceId }) => - syncService.stopWatch( + syncChannelService.stopWatch( user, _id.toString(), resourceId, @@ -118,7 +119,7 @@ export const refreshWatch = async ( const refreshesByUser = await Promise.all( r.payload.map(async ({ _id, user, expiration, ...syncPayload }) => { - const _refresh = await syncService.refreshWatch( + const _refresh = await syncChannelService.refreshWatch( user, { ...syncPayload, @@ -144,7 +145,9 @@ export const refreshWatch = async ( }; } catch (e) { if (isFullSyncRequired(e as Error)) { - void syncService.restartGoogleCalendarSync(r.user, { force: true }); + void googleSyncLifecycleService.restartGoogleCalendarSync(r.user, { + force: true, + }); resynced = true; } else { logger.error( diff --git a/packages/backend/src/sync/services/outbound/compass-google-mirror.service.test.ts b/packages/backend/src/sync/services/outbound/compass-google-mirror.service.test.ts new file mode 100644 index 000000000..14f552822 --- /dev/null +++ b/packages/backend/src/sync/services/outbound/compass-google-mirror.service.test.ts @@ -0,0 +1,67 @@ +import { ObjectId } from "mongodb"; +import { Origin, Priorities } from "@core/constants/core.constants"; +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import mongoService from "@backend/common/services/mongo.service"; +import * as eventServiceModule from "@backend/event/services/event.service"; +import compassGoogleMirrorService from "@backend/sync/services/outbound/compass-google-mirror.service"; + +const createEvent = (user: string, overrides = {}) => ({ + _id: new ObjectId(), + user, + title: "Compass event", + startDate: "2024-01-15T10:00:00.000Z", + endDate: "2024-01-15T11:00:00.000Z", + isAllDay: false, + isSomeday: false, + origin: Origin.COMPASS, + priority: Priorities.UNASSIGNED, + ...overrides, +}); + +describe("compassGoogleMirrorService", () => { + beforeAll(initSupertokens); + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterEach(() => jest.restoreAllMocks()); + afterAll(cleanupTestDb); + + it("creates a Google event for Compass-owned events without provider ids", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const event = createEvent(userId); + await mongoService.event.insertOne(event); + jest.spyOn(eventServiceModule, "_createGcal").mockResolvedValue({ + id: "google-event-id", + } as never); + + await expect( + compassGoogleMirrorService.syncCompassEventsToGoogle(userId), + ).resolves.toBe(1); + + expect(await mongoService.event.findOne({ _id: event._id })).toEqual( + expect.objectContaining({ gEventId: "google-event-id" }), + ); + }); + + it("ignores someday events and events that already have Google ids", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + await mongoService.event.insertMany([ + createEvent(userId, { isSomeday: true }), + createEvent(userId, { gEventId: "existing-google-id" }), + ]); + const createSpy = jest.spyOn(eventServiceModule, "_createGcal"); + + await expect( + compassGoogleMirrorService.syncCompassEventsToGoogle(userId), + ).resolves.toBe(0); + + expect(createSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts b/packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts new file mode 100644 index 000000000..f97ae2b8e --- /dev/null +++ b/packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts @@ -0,0 +1,94 @@ +import { type Filter } from "mongodb"; +import { MapEvent } from "@core/mappers/map.event"; +import { + type Schema_Event, + type Schema_Event_Core, +} from "@core/types/event.types"; +import mongoService from "@backend/common/services/mongo.service"; +import { _createGcal } from "@backend/event/services/event.service"; + +export const syncCompassEventsToGoogle = async ( + userId: string, +): Promise => { + const compassEvents = await mongoService.event + .find({ + user: userId, + isSomeday: false, + "recurrence.eventId": { $exists: false }, + $or: [ + { gEventId: { $exists: false } }, + { gEventId: null }, + { gEventId: "" }, + ], + } as Filter>) + .sort({ startDate: 1 }) + .toArray(); + + let syncedCount = 0; + + for (const compassEvent of compassEvents) { + if ( + !compassEvent.startDate || + !compassEvent.endDate || + !compassEvent.user + ) { + continue; + } + + const gEvent = await _createGcal( + userId, + compassEvent as unknown as Schema_Event_Core, + ); + const gEventId = gEvent.id; + + if (!gEventId) { + continue; + } + + await mongoService.event.updateOne( + { _id: compassEvent._id, user: userId }, + { $set: { gEventId } }, + ); + + syncedCount += 1; + + if (!compassEvent.recurrence?.rule) { + continue; + } + + const instances = await mongoService.event + .find({ + user: userId, + "recurrence.eventId": compassEvent._id.toString(), + }) + .sort({ startDate: 1 }) + .toArray(); + + for (const instance of instances) { + const providerData = MapEvent.toGcalInstanceProviderData( + { + ...instance, + _id: instance._id.toString(), + } as Parameters[0], + { + ...compassEvent, + _id: compassEvent._id.toString(), + gEventId, + } as Parameters[1], + ); + + await mongoService.event.updateOne( + { _id: instance._id, user: userId }, + { $set: providerData }, + ); + } + } + + return syncedCount; +}; + +const compassGoogleMirrorService = { + syncCompassEventsToGoogle, +}; + +export default compassGoogleMirrorService; diff --git a/packages/backend/src/sync/services/records/sync.records.test.ts b/packages/backend/src/sync/services/records/sync.records.test.ts new file mode 100644 index 000000000..5a3ebd62b --- /dev/null +++ b/packages/backend/src/sync/services/records/sync.records.test.ts @@ -0,0 +1,96 @@ +import { Resource_Sync } from "@core/types/sync.types"; +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import mongoService from "@backend/common/services/mongo.service"; +import syncRecords from "./sync.records"; + +describe("syncRecords", () => { + beforeAll(initSupertokens); + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterAll(cleanupTestDb); + + it("deletes all sync records for one user only", async () => { + const firstUser = await UserDriver.createUser(); + const secondUser = await UserDriver.createUser(); + + await mongoService.sync.insertMany([ + { + user: firstUser._id.toString(), + google: { events: [{ gCalendarId: "primary", nextSyncToken: "a" }] }, + }, + { + user: secondUser._id.toString(), + google: { events: [{ gCalendarId: "primary", nextSyncToken: "b" }] }, + }, + ]); + + const result = await syncRecords.deleteAllByUser(firstUser._id.toString()); + + expect(result.deletedCount).toBe(1); + expect( + await mongoService.sync.findOne({ user: firstUser._id.toString() }), + ).toBeNull(); + expect( + await mongoService.sync.findOne({ user: secondUser._id.toString() }), + ).toEqual(expect.objectContaining({ user: secondUser._id.toString() })); + }); + + it("deletes sync records that reference a Google calendar id", async () => { + const firstUser = await UserDriver.createUser(); + const secondUser = await UserDriver.createUser(); + + await mongoService.sync.insertMany([ + { + user: firstUser._id.toString(), + google: { events: [{ gCalendarId: "shared", nextSyncToken: "a" }] }, + }, + { + user: secondUser._id.toString(), + google: { events: [{ gCalendarId: "other", nextSyncToken: "b" }] }, + }, + ]); + + const result = await syncRecords.deleteAllByGcalId("shared"); + + expect(result.deletedCount).toBe(1); + expect( + await mongoService.sync.findOne({ user: firstUser._id.toString() }), + ).toBeNull(); + expect( + await mongoService.sync.findOne({ user: secondUser._id.toString() }), + ).toEqual(expect.objectContaining({ user: secondUser._id.toString() })); + }); + + it("removes Google sync data without deleting the sync record", async () => { + const user = await UserDriver.createUser(); + + await mongoService.sync.insertOne({ + user: user._id.toString(), + google: { + events: [{ gCalendarId: "primary", nextSyncToken: "token" }], + calendarlist: [ + { + gCalendarId: Resource_Sync.CALENDAR, + nextSyncToken: "calendar-token", + }, + ], + }, + }); + + const result = await syncRecords.deleteByIntegration( + "google", + user._id.toString(), + ); + + expect(result.modifiedCount).toBe(1); + expect( + await mongoService.sync.findOne({ user: user._id.toString() }), + ).toEqual(expect.not.objectContaining({ google: expect.anything() })); + }); +}); diff --git a/packages/backend/src/sync/services/records/sync.records.ts b/packages/backend/src/sync/services/records/sync.records.ts new file mode 100644 index 000000000..fc01347dd --- /dev/null +++ b/packages/backend/src/sync/services/records/sync.records.ts @@ -0,0 +1,31 @@ +import { type ClientSession } from "mongodb"; +import { Collections } from "@backend/common/constants/collections"; +import mongoService from "@backend/common/services/mongo.service"; + +export const deleteAllByGcalId = ( + gCalendarId: string, + session?: ClientSession, +) => { + return mongoService.sync.deleteMany( + { "google.events.gCalendarId": gCalendarId }, + { session }, + ); +}; + +export const deleteAllByUser = (userId: string, session?: ClientSession) => { + return mongoService.sync.deleteMany({ user: userId }, { session }); +}; + +export const deleteByIntegration = (integration: "google", userId: string) => { + return mongoService.db + .collection(Collections.SYNC) + .updateOne({ user: userId }, { $unset: { [integration]: "" } }); +}; + +const syncRecords = { + deleteAllByGcalId, + deleteAllByUser, + deleteByIntegration, +}; + +export default syncRecords; diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts deleted file mode 100644 index 70c62a596..000000000 --- a/packages/backend/src/sync/services/sync.service.test.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { ObjectId } from "mongodb"; -import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; -import { WatchSchema } from "@core/types/watch.types"; -import { UserDriver } from "@backend/__tests__/drivers/user.driver"; -import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; -import { - cleanupCollections, - cleanupTestDb, - setupTestDb, -} from "@backend/__tests__/helpers/mock.db.setup"; -import { createGoogleError } from "@backend/__tests__/mocks.gcal/errors/error.google.factory"; -import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; -import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/error.missingRefreshToken"; -import * as googleCalendarClient from "@backend/auth/services/google/clients/google.calendar.client"; -import calendarService from "@backend/calendar/services/calendar.service"; -import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; -import gcalService from "@backend/common/services/gcal/gcal.service"; -import mongoService from "@backend/common/services/mongo.service"; -import { sseServer } from "@backend/servers/sse/sse.server"; -import * as syncImportService from "@backend/sync/services/import/sync.import"; -import syncService from "@backend/sync/services/sync.service"; -import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; -import userService from "@backend/user/services/user.service"; -import userMetadataService from "@backend/user/services/user-metadata.service"; - -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- jest.mock factory delegates to requireActual */ -jest.mock("@backend/sync/util/sync.util", () => { - const actual = jest.requireActual("@backend/sync/util/sync.util"); - return { - ...actual, - isUsingGcalWebhookHttps: jest.fn(() => actual.isUsingGcalWebhookHttps()), - }; -}); -/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ - -const createWatch = async (user: string) => { - const watch = WatchSchema.parse({ - _id: new ObjectId(), - user, - resourceId: faker.string.uuid(), - expiration: new Date(Date.now() + 60_000), - gCalendarId: faker.string.uuid(), - createdAt: new Date(), - }); - - await mongoService.watch.insertOne(watch); - - return watch; -}; - -describe("SyncService", () => { - beforeAll(initSupertokens); - beforeEach(setupTestDb); - beforeEach(cleanupCollections); - afterEach(() => jest.restoreAllMocks()); - afterAll(cleanupTestDb); - - describe("deleteWatchesByUser", () => { - it("deletes only the target user's watch records and returns their identities", async () => { - const firstUser = await UserDriver.createUser(); - const secondUser = await UserDriver.createUser(); - const firstUserWatchA = await createWatch(firstUser._id.toString()); - const firstUserWatchB = await createWatch(firstUser._id.toString()); - const secondUserWatch = await createWatch(secondUser._id.toString()); - - const deleted = await syncService.deleteWatchesByUser( - firstUser._id.toString(), - ); - - expect(deleted).toEqual( - expect.arrayContaining([ - { - channelId: firstUserWatchA._id.toString(), - resourceId: firstUserWatchA.resourceId, - }, - { - channelId: firstUserWatchB._id.toString(), - resourceId: firstUserWatchB.resourceId, - }, - ]), - ); - expect(deleted).toHaveLength(2); - expect( - await mongoService.watch.countDocuments({ - user: firstUser._id.toString(), - }), - ).toBe(0); - expect( - await mongoService.watch.findOne({ _id: secondUserWatch._id }), - ).toEqual(expect.objectContaining({ user: secondUser._id.toString() })); - }); - }); - - describe("stopWatch", () => { - it("deletes the local watch record when Google returns 401", async () => { - const user = await UserDriver.createUser(); - const watch = await createWatch(user._id.toString()); - - jest - .spyOn(gcalService, "stopWatch") - .mockRejectedValue( - createGoogleError({ code: "401", responseStatus: 401 }), - ); - - await expect( - syncService.stopWatch( - user._id.toString(), - watch._id.toString(), - watch.resourceId, - ), - ).resolves.toBeUndefined(); - - expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); - }); - - it("deletes the local watch record when Google returns invalid_grant", async () => { - const user = await UserDriver.createUser(); - const watch = await createWatch(user._id.toString()); - - jest - .spyOn(gcalService, "stopWatch") - .mockRejectedValue(invalidGrant400Error); - - await expect( - syncService.stopWatch( - user._id.toString(), - watch._id.toString(), - watch.resourceId, - ), - ).resolves.toBeUndefined(); - - expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); - }); - - it("deletes the local watch record when the user is missing a refresh token", async () => { - const user = await UserDriver.createUser({ - withGoogleRefreshToken: false, - }); - const watch = await createWatch(user._id.toString()); - - jest - .spyOn(gcalService, "stopWatch") - .mockRejectedValue(missingRefreshTokenError); - - await expect( - syncService.stopWatch( - user._id.toString(), - watch._id.toString(), - watch.resourceId, - ), - ).resolves.toBeUndefined(); - - expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); - }); - - it("preserves the existing delete behavior when Google returns 404", async () => { - const user = await UserDriver.createUser(); - const watch = await createWatch(user._id.toString()); - - jest - .spyOn(gcalService, "stopWatch") - .mockRejectedValue( - createGoogleError({ code: "404", responseStatus: 404 }), - ); - - await expect( - syncService.stopWatch( - user._id.toString(), - watch._id.toString(), - watch.resourceId, - ), - ).resolves.toBeUndefined(); - - expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); - }); - - it("rethrows unexpected Google stop errors and keeps the local watch", async () => { - const user = await UserDriver.createUser(); - const watch = await createWatch(user._id.toString()); - - jest - .spyOn(gcalService, "stopWatch") - .mockRejectedValue( - createGoogleError({ code: "500", responseStatus: 500 }), - ); - - await expect( - syncService.stopWatch( - user._id.toString(), - watch._id.toString(), - watch.resourceId, - ), - ).rejects.toMatchObject({ code: "500" }); - - expect( - await mongoService.watch.findOne({ - _id: watch._id, - resourceId: watch.resourceId, - }), - ).toEqual(expect.objectContaining({ user: user._id.toString() })); - }); - }); - - describe("stopWatches", () => { - it("returns early when the user has no stored watches", async () => { - const user = await UserDriver.createUser({ withGoogle: false }); - const getGcalClientSpy = jest.spyOn( - googleCalendarClient, - "getGcalClient", - ); - - await expect( - syncService.stopWatches(user._id.toString()), - ).resolves.toEqual([]); - - expect(getGcalClientSpy).not.toHaveBeenCalled(); - }); - - it("deletes local watch records when the user is missing a refresh token", async () => { - const user = await UserDriver.createUser({ - withGoogleRefreshToken: false, - }); - const watch = await createWatch(user._id.toString()); - const getGcalClientSpy = jest.spyOn( - googleCalendarClient, - "getGcalClient", - ); - - await expect( - syncService.stopWatches(user._id.toString()), - ).resolves.toEqual([]); - - expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); - expect(getGcalClientSpy).not.toHaveBeenCalled(); - }); - }); - - describe("handleGcalNotification", () => { - it("ignores expired notifications when no local watch record remains", async () => { - const cleanupSpy = jest - .spyOn(syncService, "cleanupStaleWatchChannel") - .mockResolvedValue(false); - - await expect( - syncService.handleGcalNotification({ - resource: Resource_Sync.EVENTS, - channelId: new ObjectId(), - resourceId: faker.string.uuid(), - resourceState: XGoogleResourceState.EXISTS, - expiration: faker.date.future(), - }), - ).resolves.toBe("IGNORED"); - - expect(cleanupSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe("cleanupStaleWatchChannel", () => { - it("ignores stale notifications when no exact watch record exists", async () => { - const user = await UserDriver.createUser(); - const watch = await createWatch(user._id.toString()); - const stopWatchSpy = jest.spyOn(syncService, "stopWatch"); - - await expect( - syncService.cleanupStaleWatchChannel({ - resource: Resource_Sync.EVENTS, - channelId: new ObjectId(), - resourceId: watch.resourceId, - resourceState: XGoogleResourceState.EXISTS, - expiration: faker.date.future(), - }), - ).resolves.toBe(false); - - expect(stopWatchSpy).not.toHaveBeenCalled(); - expect(await mongoService.watch.findOne({ _id: watch._id })).toEqual( - expect.objectContaining({ user: user._id.toString() }), - ); - }); - }); - - describe("importIncremental", () => { - it("emits INCREMENTAL operation when incremental import is ignored", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "COMPLETED" } }, - }); - - await syncService.importIncremental(userId); - - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "INCREMENTAL", - status: "IGNORED", - message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, - }); - }); - - it("emits INCREMENTAL operation when incremental import completes", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - const createSyncImportSpy = jest - .spyOn(syncImportService, "createSyncImport") - .mockResolvedValue({ - importLatestEvents: jest.fn().mockResolvedValue({}), - } as unknown as Awaited< - ReturnType - >); - - await syncService.importIncremental(userId); - - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "INCREMENTAL", - status: "COMPLETED", - }); - - createSyncImportSpy.mockRestore(); - }); - - it("emits INCREMENTAL operation when incremental import fails", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - const error = new Error("incremental failed"); - const createSyncImportSpy = jest - .spyOn(syncImportService, "createSyncImport") - .mockResolvedValue({ - importLatestEvents: jest.fn().mockRejectedValue(error), - } as unknown as Awaited< - ReturnType - >); - - await expect(syncService.importIncremental(userId)).rejects.toThrow( - "incremental failed", - ); - - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "INCREMENTAL", - status: "ERRORED", - message: `Incremental Google Calendar sync failed for user: ${userId}`, - }); - - createSyncImportSpy.mockRestore(); - }); - }); - - describe("startGoogleCalendarSync", () => { - it("initializes calendars, events, and sync metadata", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - - await syncService.startGoogleCalendarSync(userId); - - const listCalendarsForUser = - calendarService.getByUser.bind(calendarService); - const calendars = await listCalendarsForUser(userId); - expect(calendars.length).toBeGreaterThan(0); - - const syncRecord = await mongoService.sync.findOne({ user: userId }); - expect(syncRecord?.google?.events?.length ?? 0).toBeGreaterThan(0); - - const eventCount = await mongoService.event.countDocuments({ - user: userId, - }); - expect(eventCount).toBeGreaterThan(0); - }); - - it("starts Google watches only after full import succeeds", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - const callOrder: string[] = []; - const importFull = syncService.importFull.bind(syncService); - const startWatching = - syncService.startWatchingGcalResources.bind(syncService); - - const importFullSpy = jest - .spyOn(syncService, "importFull") - .mockImplementation(async (...args) => { - callOrder.push("importFull"); - return importFull(...args); - }); - const startWatchingSpy = jest - .spyOn(syncService, "startWatchingGcalResources") - .mockImplementation(async (...args) => { - callOrder.push("startWatching"); - return startWatching(...args); - }); - - await syncService.startGoogleCalendarSync(userId); - - expect(callOrder).toEqual(["importFull", "startWatching"]); - - importFullSpy.mockRestore(); - startWatchingSpy.mockRestore(); - }); - - it("does not create watches when full import fails before token persistence", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - const importError = new Error("import failed before sync token"); - const importFullSpy = jest - .spyOn(syncService, "importFull") - .mockRejectedValue(importError); - const startWatchingSpy = jest.spyOn( - syncService, - "startWatchingGcalResources", - ); - - await expect(syncService.startGoogleCalendarSync(userId)).rejects.toThrow( - importError, - ); - - expect(startWatchingSpy).not.toHaveBeenCalled(); - expect(await mongoService.watch.countDocuments({ user: userId })).toBe(0); - - importFullSpy.mockRestore(); - startWatchingSpy.mockRestore(); - }); - - it("persists event sync tokens without https so local sync can settle healthy", async () => { - const user = await UserDriver.createUser(); - const userId = user._id.toString(); - (isUsingGcalWebhookHttps as jest.Mock).mockReturnValue(false); - - await syncService.startGoogleCalendarSync(userId); - - const syncRecord = await mongoService.sync.findOne({ user: userId }); - const metadata = await userMetadataService.fetchUserMetadata(userId); - - expect(syncRecord?.google?.events?.length ?? 0).toBeGreaterThan(0); - expect( - syncRecord?.google?.events?.every(({ nextSyncToken }) => - Boolean(nextSyncToken), - ), - ).toBe(true); - expect(metadata.google?.connectionState).toBe("HEALTHY"); - - (isUsingGcalWebhookHttps as jest.Mock).mockRestore(); - }); - }); - - describe("startWatchingGcalResources", () => { - it("skips direct Google watch setup when the Google webhook URL is not HTTPS", async () => { - (isUsingGcalWebhookHttps as jest.Mock).mockReturnValue(false); - const startCalendarWatchSpy = jest.spyOn( - syncService, - "startWatchingGcalCalendars", - ); - const startEventWatchSpy = jest.spyOn( - syncService, - "startWatchingGcalEvents", - ); - - await expect( - syncService.startWatchingGcalResources( - "507f1f77bcf86cd799439011", - [{ gCalendarId: Resource_Sync.CALENDAR }, { gCalendarId: "primary" }], - {} as never, - ), - ).resolves.toEqual([]); - - expect(startCalendarWatchSpy).not.toHaveBeenCalled(); - expect(startEventWatchSpy).not.toHaveBeenCalled(); - - (isUsingGcalWebhookHttps as jest.Mock).mockRestore(); - }); - - it("starts Google watches when the Google webhook URL is HTTPS", async () => { - (isUsingGcalWebhookHttps as jest.Mock).mockReturnValue(true); - const startCalendarWatchSpy = jest - .spyOn(syncService, "startWatchingGcalCalendars") - .mockResolvedValue({ acknowledged: true } as never); - const startEventWatchSpy = jest - .spyOn(syncService, "startWatchingGcalEvents") - .mockResolvedValue({ acknowledged: true } as never); - - await expect( - syncService.startWatchingGcalResources( - "507f1f77bcf86cd799439011", - [{ gCalendarId: Resource_Sync.CALENDAR }, { gCalendarId: "primary" }], - {} as never, - ), - ).resolves.toHaveLength(2); - - expect(startCalendarWatchSpy).toHaveBeenCalledTimes(1); - expect(startEventWatchSpy).toHaveBeenCalledTimes(1); - - (isUsingGcalWebhookHttps as jest.Mock).mockRestore(); - startCalendarWatchSpy.mockRestore(); - startEventWatchSpy.mockRestore(); - }); - }); - - describe("restartGoogleCalendarSync", () => { - it("restarts the import workflow and completes successfully", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "RESTART" } }, - }); - - await syncService.restartGoogleCalendarSync(userId); - - const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("COMPLETED"); - - const listCalendarsForUser = - calendarService.getByUser.bind(calendarService); - const calendars = await listCalendarsForUser(userId); - expect(calendars.length).toBeGreaterThan(0); - expect(importEndSpy).toHaveBeenCalledWith( - userId, - expect.objectContaining({ - operation: "INCREMENTAL", - status: "COMPLETED", - }), - ); - }); - - it("skips restart when import is completed and not forced", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "COMPLETED" } }, - }); - - const stopSpy = jest.spyOn(userService, "stopGoogleCalendarSync"); - const startSpy = jest.spyOn(syncService, "startGoogleCalendarSync"); - - await syncService.restartGoogleCalendarSync(userId); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(startSpy).not.toHaveBeenCalled(); - - const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("COMPLETED"); - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "INCREMENTAL", - status: "IGNORED", - message: `User ${userId} gcal import is in progress or completed, ignoring this request`, - }); - - stopSpy.mockRestore(); - startSpy.mockRestore(); - }); - - it("forces restart when import is completed", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "COMPLETED" } }, - }); - - const stopSpy = jest - .spyOn(userService, "stopGoogleCalendarSync") - .mockResolvedValue(); - const startSpy = jest - .spyOn(syncService, "startGoogleCalendarSync") - .mockResolvedValue({ eventsCount: 0, calendarsCount: 0 }); - - await syncService.restartGoogleCalendarSync(userId, { force: true }); - - expect(stopSpy).toHaveBeenCalledWith(userId); - expect(startSpy).toHaveBeenCalledWith(userId); - - const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("COMPLETED"); - - stopSpy.mockRestore(); - startSpy.mockRestore(); - }); - - it("ignores a duplicate restart while the first full sync is still starting", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - let resolveFetchMetadata: (() => void) | undefined; - const fetchMetadataDeferred = new Promise((resolve) => { - resolveFetchMetadata = resolve; - }); - let fetchMetadataCallCount = 0; - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "RESTART" } }, - }); - - const fetchMetadataSpy = jest - .spyOn(userService, "fetchUserMetadata") - .mockImplementation(async (targetUserId) => { - fetchMetadataCallCount += 1; - - if (fetchMetadataCallCount === 1) { - await fetchMetadataDeferred; - } - - return userMetadataService.fetchUserMetadata(targetUserId); - }); - const startSpy = jest - .spyOn(syncService, "startGoogleCalendarSync") - .mockResolvedValue({ eventsCount: 0, calendarsCount: 0 }); - const stopSpy = jest - .spyOn(userService, "stopGoogleCalendarSync") - .mockResolvedValue(); - - const firstRestart = syncService.restartGoogleCalendarSync(userId, { - force: true, - }); - await Promise.resolve(); - - const secondRestart = syncService.restartGoogleCalendarSync(userId, { - force: true, - }); - - resolveFetchMetadata?.(); - - await Promise.all([firstRestart, secondRestart]); - - expect(startSpy).toHaveBeenCalledTimes(1); - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "REPAIR", - status: "IGNORED", - message: `User ${userId} gcal import is in progress or completed, ignoring this request`, - }); - - fetchMetadataSpy.mockRestore(); - startSpy.mockRestore(); - stopSpy.mockRestore(); - }); - - it("cleans up partial watch state when restart fails", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - const stopWatchesSpy = jest - .spyOn(syncService, "stopWatches") - .mockImplementation(async (targetUserId) => { - await syncService.deleteWatchesByUser(targetUserId); - return []; - }); - const startSpy = jest - .spyOn(syncService, "startGoogleCalendarSync") - .mockImplementation(async () => { - await mongoService.watch.insertOne( - WatchSchema.parse({ - _id: mongoService.objectId(), - user: userId, - resourceId: faker.string.uuid(), - expiration: faker.date.future(), - gCalendarId: faker.string.uuid(), - createdAt: new Date(), - }), - ); - - throw new Error("sync failed"); - }); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "RESTART" } }, - }); - - await syncService.restartGoogleCalendarSync(userId, { force: true }); - - const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("ERRORED"); - expect(await mongoService.watch.countDocuments({ user: userId })).toBe(0); - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "REPAIR", - status: "ERRORED", - message: "Google Calendar repair failed. Please try again.", - }); - - stopWatchesSpy.mockRestore(); - startSpy.mockRestore(); - }); - - it("prunes Google data and notifies revoked state when repair loses access", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - const googleRevokedSpy = jest.spyOn(sseServer, "handleGoogleRevoked"); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - const startSpy = jest - .spyOn(syncService, "startGoogleCalendarSync") - .mockRejectedValue(invalidGrant400Error); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "RESTART" } }, - }); - - await syncService.restartGoogleCalendarSync(userId, { force: true }); - - const storedUser = await mongoService.user.findOne({ _id: user._id }); - const metadata = await userMetadataService.fetchUserMetadata(userId); - - expect(storedUser?.google?.gRefreshToken).toBe(""); - expect(metadata.google?.connectionState).toBe("RECONNECT_REQUIRED"); - expect(googleRevokedSpy).toHaveBeenCalledWith(userId); - expect(importEndSpy).not.toHaveBeenCalledWith( - userId, - expect.objectContaining({ status: "ERRORED" }), - ); - - startSpy.mockRestore(); - }); - - it("emits a friendly quota error when Google repair hits rate limits", async () => { - const { user } = await UtilDriver.setupTestUser(); - const userId = user._id.toString(); - const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); - const quotaError = createGoogleError({ - code: "403", - responseStatus: 403, - message: "Quota exceeded", - }); - if (quotaError.response) { - quotaError.response.data = { - error: { - message: - "Quota exceeded for quota metric 'Queries' and limit 'Queries per minute per user'.", - errors: [{ reason: "quotaExceeded" }], - }, - }; - } - const startSpy = jest - .spyOn(syncService, "startGoogleCalendarSync") - .mockRejectedValue(quotaError); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "RESTART" } }, - }); - - await syncService.restartGoogleCalendarSync(userId, { force: true }); - - const metadata = await userMetadataService.fetchUserMetadata(userId); - - expect(metadata.sync?.importGCal).toBe("ERRORED"); - expect(importEndSpy).toHaveBeenCalledWith(userId, { - operation: "REPAIR", - status: "ERRORED", - message: - "Google Calendar repair hit a Google API limit. Please wait a few minutes and try again.", - }); - - startSpy.mockRestore(); - }); - }); -}); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts deleted file mode 100644 index ff7bae858..000000000 --- a/packages/backend/src/sync/services/sync.service.ts +++ /dev/null @@ -1,1001 +0,0 @@ -import { type ClientSession, type Filter, ObjectId } from "mongodb"; -import { Logger } from "@core/logger/winston.logger"; -import { MapEvent } from "@core/mappers/map.event"; -import { - type Schema_Event, - type Schema_Event_Core, -} from "@core/types/event.types"; -import { type gCalendar } from "@core/types/gcal"; -import { - type Params_WatchEvents, - type Payload_Sync_Notif, - Resource_Sync, - type Result_Watch_Stop, - XGoogleResourceState, -} from "@core/types/sync.types"; -import { ExpirationDateSchema } from "@core/types/type.utils"; -import { WatchSchema } from "@core/types/watch.types"; -import { - shouldDoIncrementalGCalSync, - shouldImportGCal, -} from "@core/util/event/event.util"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; -import calendarService from "@backend/calendar/services/calendar.service"; -import { MONGO_BATCH_SIZE } from "@backend/common/constants/backend.constants"; -import { Collections } from "@backend/common/constants/collections"; -import { error } from "@backend/common/errors/handlers/error.handler"; -import { - GcalError, - getGoogleRepairErrorMessage, -} from "@backend/common/errors/integration/gcal/gcal.errors"; -import { SyncError } from "@backend/common/errors/sync/sync.errors"; -import { WatchError } from "@backend/common/errors/sync/watch.errors"; -import { UserError } from "@backend/common/errors/user/user.errors"; -import gcalService from "@backend/common/services/gcal/gcal.service"; -import { - getGoogleErrorStatus, - isInvalidGoogleToken, -} from "@backend/common/services/gcal/gcal.utils"; -import mongoService from "@backend/common/services/mongo.service"; -import { _createGcal } from "@backend/event/services/event.service"; -import { sseServer } from "@backend/servers/sse/sse.server"; -import { createSyncImport } from "@backend/sync/services/import/sync.import"; -import { - prepWatchMaintenanceForUser, - pruneSync, - refreshWatch, -} from "@backend/sync/services/maintain/sync.maintenance"; -import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; -import { - getSync, - isWatchingGoogleResource, - updateSync, -} from "@backend/sync/util/sync.queries"; -import { - createConcurrencyLimiter, - getChannelExpiration, - isMissingGoogleRefreshToken, - isUsingGcalWebhookHttps, -} from "@backend/sync/util/sync.util"; -import { findCompassUserBy } from "@backend/user/queries/user.queries"; -import userMetadataService from "@backend/user/services/user-metadata.service"; - -const logger = Logger("app:sync.service"); - -class SyncService { - private activeFullSyncRestarts = new Set(); - - deleteAllByGcalId = async (gCalendarId: string, session?: ClientSession) => { - const delRes = await mongoService.sync.deleteMany( - { "google.events.gCalendarId": gCalendarId }, - { session }, - ); - - return delRes; - }; - - deleteAllByUser = async (userId: string, session?: ClientSession) => { - const delRes = await mongoService.sync.deleteMany( - { user: userId }, - { session }, - ); - - return delRes; - }; - - deleteByIntegration = async (integration: "google", userId: string) => { - const response = await mongoService.db - .collection(Collections.SYNC) - .updateOne({ user: userId }, { $unset: { [integration]: "" } }); - - return response; - }; - - deleteWatchesByUser = async ( - user: string, - session?: ClientSession, - ): Promise => { - const watches = await mongoService.watch - .find({ user }, { session }) - .toArray(); - - await mongoService.watch.deleteMany({ user }, { session }); - - return watches.map(({ _id, resourceId }) => ({ - channelId: _id.toString(), - resourceId, - })); - }; - - private prepareStopWatches = async ( - user: string, - gcal?: gCalendar, - session?: ClientSession, - ) => { - const watches = await mongoService.watch - .find({ user }, { session }) - .toArray(); - - if (watches.length === 0 || gcal) { - return { watches, gcal }; - } - - const compassUser = await findCompassUserBy("_id", user); - - if (!compassUser) { - throw error(UserError.UserNotFound, "User not found"); - } - - if (!compassUser.google?.gRefreshToken) { - await mongoService.watch.deleteMany({ user }, { session }); - - logger.warn( - "Google refresh token is missing. Corresponding watch records deleted", - ); - - return { watches: [], gcal }; - } - - return { - watches, - gcal: await getGcalClient(user), - }; - }; - - async cleanupStaleWatchChannel({ - channelId, - resourceId, - }: Payload_Sync_Notif): Promise { - const channel = await mongoService.watch.findOne({ - _id: channelId, - resourceId, - }); - - if (!channel) { - logger.warn( - `Ignoring stale Google notification because no exact watch exists for channelId: ${channelId.toString()}, resourceId: ${resourceId}`, - ); - - return false; - } - - try { - await this.stopWatch( - channel.user, - channel._id.toString(), - channel.resourceId, - ); - - logger.warn( - `Cleaned up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()} with resourceId: ${channel.resourceId}`, - ); - - return true; - } catch (error) { - logger.error( - `Failed to clean up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()}`, - error, - ); - - return false; - } - } - - handleGcalNotification = async (payload: Payload_Sync_Notif) => { - const { channelId, resourceId, resourceState, resource } = payload; - const { expiration } = payload; - - if (resourceState === XGoogleResourceState.SYNC) { - logger.info( - `${resource} sync initialized for channelId: ${payload.channelId.toString()}`, - ); - - return "INITIALIZED"; - } - - const watch = await mongoService.watch.findOne({ - _id: channelId, - resourceId, - expiration: { $gte: expiration }, - }); - - if (!watch) { - // clean up stale watch channel; - const cleanedUp = await this.cleanupStaleWatchChannel(payload); - - if (cleanedUp) return "IGNORED"; - - logger.warn( - `Ignoring notification because no active watch record exists for channel: ${payload.channelId.toString()}`, - ); - - return "IGNORED"; - } - - const sync = await getSync({ userId: watch.user, resource }); - - if (!sync) { - // clean up stale watch channel; - const cleanedUp = await this.cleanupStaleWatchChannel(payload); - - if (cleanedUp) return "IGNORED"; - - logger.warn( - `Ignoring notification because no sync record exists for channel: ${payload.channelId.toString()}`, - ); - - return "IGNORED"; - } - - const userId = sync.user; - const { events = [], calendarlist = [] } = sync.google ?? {}; - const channels = [...events, ...calendarlist]; - const channel = channels.find((e) => e.gCalendarId === watch.gCalendarId); - const calendarId = channel?.gCalendarId; - const nextSyncToken = channel?.nextSyncToken; - - if (!nextSyncToken) { - throw error( - SyncError.NoSyncToken, - `Notification not handled because no sync token found for calendarId: ${calendarId}`, - ); - } - - // Get the Google Calendar client - const gcal = await getGcalClient(userId); - - // Create and use the notification handler - const handler = new GCalNotificationHandler( - gcal, - resource, - userId, - watch.gCalendarId, - nextSyncToken, - ); - - await handler.handleNotification(); - - sseServer.handleBackgroundCalendarChange(userId); - - const result = "PROCESSED"; - - logger.info( - `GCal Notification for user: ${userId}, calendarId: ${calendarId} ${result}`, - ); - - return result; - }; - - importFull = async ( - gcal: gCalendar, - gCalendarIds: string[], - userId: string, - ) => { - const session = await mongoService.startSession({ - causalConsistency: true, - }); - - session.startTransaction(); - - try { - const syncImport = await createSyncImport(gcal); - - const eventImports = await Promise.all( - gCalendarIds.map(async (gCalId) => { - const { nextSyncToken, ...result } = await syncImport.importAllEvents( - userId, - gCalId, - 2500, - ); - - await updateSync( - Resource_Sync.EVENTS, - userId, - gCalId, - { nextSyncToken }, - session, - ); - - return { gCalId, ...result }; - }), - ); - - await session.commitTransaction(); - - return eventImports; - } catch (error: unknown) { - await session.abortTransaction(); - - throw error; - } - }; - - importIncremental = async ( - userId: string, - gcal?: gCalendar, - perPage = 1000, - ) => { - logger.info( - `Starting incremental Google Calendar sync for user: ${userId}`, - ); - - try { - sseServer.handleImportGCalStart(userId); - - const userMeta = await userMetadataService.fetchUserMetadata( - userId, - undefined, - { - skipAssessment: true, - }, - ); - const proceed = shouldDoIncrementalGCalSync(userMeta); - - if (!proceed) { - sseServer.handleImportGCalEnd(userId, { - operation: "INCREMENTAL", - status: "IGNORED", - message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, - }); - - return; - } - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "IMPORTING" } }, - }); - - const syncImport = gcal - ? await createSyncImport(gcal) - : await createSyncImport(userId); - - const result = await syncImport.importLatestEvents(userId, perPage); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "COMPLETED" } }, - }); - - sseServer.handleImportGCalEnd(userId, { - operation: "INCREMENTAL", - status: "COMPLETED", - }); - sseServer.handleBackgroundCalendarChange(userId); - - return result; - } catch (error) { - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "ERRORED" } }, - }); - - logger.error( - `Incremental Google Calendar sync failed for user: ${userId}`, - error, - ); - - sseServer.handleImportGCalEnd(userId, { - operation: "INCREMENTAL", - status: "ERRORED", - message: `Incremental Google Calendar sync failed for user: ${userId}`, - }); - - throw error; - } - }; - - refreshWatch = async ( - userId: string, - payload: Params_WatchEvents, - gcal?: gCalendar, - ) => { - if (!gcal) gcal = await getGcalClient(userId); - - const watchExists = payload.channelId && payload.resourceId; - - if (watchExists) { - await this.stopWatch(userId, payload.channelId, payload.resourceId, gcal); - } - - const watchResult = await this.startWatchingGcalResources( - userId, - [{ gCalendarId: payload.gCalendarId, quotaUser: payload.quotaUser }], - gcal, - ); - - return watchResult[0]; - }; - - runMaintenance = async () => { - const cursor = mongoService.user.find().batchSize(MONGO_BATCH_SIZE); - const users: ObjectId[] = []; - const result = { - deleted: 0, - refreshed: 0, - ignored: 0, - pruned: 0, - revoked: 0, - resynced: 0, - }; - - for await (const user of cursor) { - users.push(user._id); - } - - const limit = createConcurrencyLimiter(5); - - const run = await Promise.all( - users.map((user) => - limit(() => - this.runMaintenanceByUser(user.toString(), { - log: false, - }).catch((error) => { - logger.error( - `Error running sync maintenance for user: ${user.toString()}`, - error, - ); - - return { - ignore: [{ user: user.toString(), payload: [] }], - prune: [{ user: user.toString(), payload: [] }], - refresh: [{ user: user.toString(), payload: [] }], - ...result, - }; - }), - ), - ), - ); - - const results = run.reduce( - (acc, res) => ({ - deleted: acc.deleted + res.deleted, - refreshed: acc.refreshed + res.refreshed, - ignored: acc.ignored + res.ignored, - pruned: acc.pruned + res.pruned, - revoked: acc.revoked + res.revoked, - resynced: acc.resynced + res.resynced, - }), - result, - ); - - logger.debug(`Sync Maintenance Results: - IGNORED: ${results.ignored} - PRUNED: ${results.pruned} - REFRESHED: ${results.refreshed} - - DELETED DURING PRUNE: ${results.deleted} - REVOKED SESSION DURING REFRESH: ${results.revoked} - RESYNCED DURING REFRESH: ${results.resynced} - `); - - return results; - }; - - runMaintenanceByUser = async ( - userId: string, - params: { dry?: boolean; log?: boolean } = { log: true }, - ) => { - const user = await findCompassUserBy("_id", userId); - const maintenance = await prepWatchMaintenanceForUser(userId); - const ignore = [{ user: userId, payload: maintenance.ignore }]; - const prune = [{ user: userId, payload: maintenance.prune }]; - const refresh = [{ user: userId, payload: maintenance.refresh }]; - - const result = { - ignore, - prune, - refresh, - user: user?.email || "Not found", - ignored: 0, - pruned: 0, - refreshed: 0, - revoked: 0, - deleted: 0, - resynced: 0, - }; - - if (params?.dry) return result; - - const pruneResult = await pruneSync(prune); - const pruned = pruneResult.filter((p) => !p.deletedUserData); - const deletedDuringPrune = pruneResult.filter((p) => p.deletedUserData); - const refreshResult = await refreshWatch(refresh); - const refreshed = refreshResult; - const resynced = refreshResult.filter((r) => r.resynced); - - if (params?.log) { - logger.debug(`Sync Maintenance Results: - IGNORED: ${ignore.length} - PRUNED: ${pruned.flatMap((p) => p.results).length} - REFRESHED: ${refreshed.flatMap((r) => r.results.filter((r) => r.success)).length} - - DELETED DURING PRUNE: ${deletedDuringPrune.map((r) => r.user).toString()} - RESYNCED DURING REFRESH: ${resynced.map((r) => r.user).toString()} - `); - } - - return { - ...result, - ignored: ignore.flatMap(({ payload }) => payload).length, - pruned: pruned.flatMap(({ results }) => results).length, - refreshed: refreshed.flatMap(({ results }) => - results.filter((r) => r.success), - ).length, - deleted: deletedDuringPrune.length, - resynced: resynced.length, - }; - }; - - restartGoogleCalendarSync = async ( - userId: string, - options: { force?: boolean } = {}, - ) => { - const { default: userService } = await import( - "@backend/user/services/user.service" - ); - const isForce = options.force === true; - const operation = isForce ? "REPAIR" : "INCREMENTAL"; - const ignoreMessage = `User ${userId} gcal import is in progress or completed, ignoring this request`; - - if (this.activeFullSyncRestarts.has(userId)) { - sseServer.handleImportGCalEnd(userId, { - operation, - status: "IGNORED", - message: ignoreMessage, - }); - return; - } - - this.activeFullSyncRestarts.add(userId); - - try { - const userMeta = await userService.fetchUserMetadata(userId); - const importStatus = userMeta.sync?.importGCal; - const isImporting = importStatus === "IMPORTING"; - const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); - - if (!proceed) { - sseServer.handleImportGCalEnd(userId, { - operation, - status: "IGNORED", - message: ignoreMessage, - }); - - return; - } - - logger.warn( - `Restarting Google Calendar sync for user: ${userId}${isForce ? " (forced)" : ""}`, - ); - sseServer.handleImportGCalStart(userId); - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "IMPORTING" } }, - }); - - await userService.stopGoogleCalendarSync(userId); - const importResults = await this.startGoogleCalendarSync(userId); - - await syncCompassEventsToGoogle(userId).catch((err) => { - logger.error( - `Failed to sync Compass events to Google Calendar for user: ${userId}`, - err, - ); - }); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "COMPLETED" } }, - }); - - sseServer.handleImportGCalEnd(userId, { - operation, - status: "COMPLETED", - ...importResults, - }); - sseServer.handleBackgroundCalendarChange(userId); - } catch (err) { - try { - await userService.stopGoogleCalendarSync(userId); - } catch (cleanupError) { - logger.error( - `Failed to clean up partial Google Calendar sync state for user: ${userId}`, - cleanupError, - ); - } - - if (isInvalidGoogleToken(err)) { - logger.warn( - `Google Calendar repair failed because access was revoked for user: ${userId}`, - ); - - await userService.pruneGoogleData(userId); - sseServer.handleGoogleRevoked(userId); - return; - } - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "ERRORED" } }, - }); - - logger.error(`Re-sync failed for user: ${userId}`, err); - - sseServer.handleImportGCalEnd(userId, { - operation, - status: "ERRORED", - message: getGoogleRepairErrorMessage(err), - }); - } finally { - this.activeFullSyncRestarts.delete(userId); - } - }; - - startGoogleCalendarSync = async ( - user: string, - ): Promise<{ eventsCount: number; calendarsCount: number }> => { - const gcal = await getGcalClient(user); - - const calendarInit = await calendarService.initializeGoogleCalendars( - user, - gcal, - ); - - const gCalendarIds = calendarInit.googleCalendars - .map(({ id }) => id) - .filter((id): id is string => id !== undefined && id !== null); - - const importResults = await this.importFull(gcal, gCalendarIds, user); - - if (isUsingGcalWebhookHttps()) { - await this.startWatchingGcalResources( - user, - [ - { gCalendarId: Resource_Sync.CALENDAR }, - ...gCalendarIds.map((gCalendarId) => ({ gCalendarId })), - ], - gcal, - ); - } - - const eventsCount = importResults.reduce( - (sum, result) => sum + result.totalChanged, - 0, - ); - - return { - eventsCount, - calendarsCount: gCalendarIds.length, - }; - }; - - startWatchingGcalCalendars = async ( - user: string, - params: Pick, - gcal: gCalendar, - ): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> => { - try { - const alreadyWatching = await isWatchingGoogleResource( - user, - Resource_Sync.CALENDAR, - ); - - if (alreadyWatching) { - logger.error( - `Skipped Start Watch for ${Resource_Sync.CALENDAR}`, - WatchError.CalendarWatchExists, - ); - - return { acknowledged: false }; - } - - const expiration = getChannelExpiration(); - const _id = new ObjectId(); - const channelId = _id.toString(); - - const { watch: gcalWatch } = await gcalService.watchCalendars(gcal, { - ...params, - channelId, - expiration, - }); - const resourceId = gcalWatch.resourceId; - - if (!resourceId) { - throw error( - GcalError.Unsure, - "Calendar watch response missing resourceId", - ); - } - - const watch = await mongoService.watch - .insertOne( - WatchSchema.parse({ - _id, - user, - gCalendarId: Resource_Sync.CALENDAR, - resourceId, - expiration: ExpirationDateSchema.parse(gcalWatch.expiration), - createdAt: new Date(), - }), - ) - .catch(async (error) => { - await this.stopWatch(user, channelId, resourceId, gcal); - - throw error; - }); - - return watch; - } catch (err) { - logger.error(`Error starting calendar watch for user: ${user}`, err); - - return { acknowledged: false }; - } - }; - - startWatchingGcalEvents = async ( - user: string, - params: Pick, - gcal: gCalendar, - ): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> => { - try { - const alreadyWatching = await isWatchingGoogleResource( - user, - params.gCalendarId, - ); - - if (alreadyWatching) { - logger.error( - `Skipped Start Watch for ${params.gCalendarId} ${Resource_Sync.EVENTS}`, - WatchError.EventWatchExists, - ); - - return { acknowledged: false }; - } - - const expiration = getChannelExpiration(); - const _id = new ObjectId(); - const channelId = _id.toString(); - - const { watch: gcalWatch } = await gcalService.watchEvents(gcal, { - ...params, - channelId, - expiration, - }); - const resourceId = gcalWatch.resourceId; - - if (!resourceId) { - throw error( - GcalError.Unsure, - "Event watch response missing resourceId", - ); - } - - const watch = await mongoService.watch - .insertOne( - WatchSchema.parse({ - _id, - user, - gCalendarId: params.gCalendarId, - resourceId, - expiration: ExpirationDateSchema.parse(gcalWatch.expiration), - createdAt: new Date(), - }), - ) - .catch(async (error) => { - await this.stopWatch(user, channelId, resourceId, gcal); - - throw error; - }); - - return watch; - } catch (err) { - logger.error(`Error starting events watch for user: ${user}`, err); - - return { acknowledged: false }; - } - }; - - startWatchingGcalResources = async ( - userId: string, - watchParams: Pick[], - gcal: gCalendar, - ) => { - if (!isUsingGcalWebhookHttps()) { - return []; - } - - return Promise.all( - watchParams.map(async (params) => { - if (params.gCalendarId === (Resource_Sync.CALENDAR as string)) { - return this.startWatchingGcalCalendars(userId, params, gcal); - } - - return this.startWatchingGcalEvents(userId, params, gcal); - }), - ).then((results) => results.filter((r) => r !== undefined)); - }; - - stopWatch = async ( - user: string, - channelId: string, - resourceId: string, - gcal?: gCalendar, - quotaUser?: string, - session?: ClientSession, - ) => { - const filter = { user, _id: new ObjectId(channelId), resourceId }; - - try { - if (!gcal) gcal = await getGcalClient(user); - - await gcalService.stopWatch(gcal, { - quotaUser, - channelId, - resourceId, - }); - - await mongoService.watch.deleteOne(filter, { session }); - - return { channelId, resourceId }; - } catch (e) { - const status = getGoogleErrorStatus(e); - - if (status === 404) { - await mongoService.watch.deleteOne(filter, { session }); - - logger.warn( - "Channel no longer exists. Corresponding sync record deleted", - ); - - return undefined; - } - - if (status === 401 || isInvalidGoogleToken(e)) { - await mongoService.watch.deleteOne(filter, { session }); - - logger.warn( - "Google authorization is no longer valid. Corresponding sync record deleted", - ); - - return undefined; - } - - if (isMissingGoogleRefreshToken(e)) { - await mongoService.watch.deleteOne(filter, { session }); - - logger.warn( - "Google refresh token is missing. Corresponding watch record deleted", - ); - - return undefined; - } - - throw e; - } - }; - - stopWatches = async ( - user: string, - gcal?: gCalendar, - quotaUser?: string, - session?: ClientSession, - ): Promise => { - const prepared = await this.prepareStopWatches(user, gcal, session); - - if (prepared.watches.length === 0) { - return []; - } - - logger.debug( - `Stopping ${prepared.watches.length} gcal event watches for user: ${user}`, - ); - const result = await Promise.all( - prepared.watches.map(async ({ _id, resourceId }) => - this.stopWatch( - user, - _id.toString(), - resourceId, - prepared.gcal, - quotaUser, - session, - ).catch((error) => { - logger.error( - `Error stopping watch for user: ${user}, channelId: ${_id.toString()}`, - error, - ); - - return undefined; - }), - ), - ); - - const stopped = result.filter( - (identity): identity is { channelId: string; resourceId: string } => - identity !== undefined, - ); - - return stopped; - }; -} - -const syncCompassEventsToGoogle = async (userId: string): Promise => { - const compassEvents = await mongoService.event - .find({ - user: userId, - isSomeday: false, - "recurrence.eventId": { $exists: false }, - $or: [ - // no gEventId means it has not been synced to Google yet - { gEventId: { $exists: false } }, - { gEventId: null }, - { gEventId: "" }, - ], - } as Filter>) - .sort({ startDate: 1 }) - .toArray(); - - let syncedCount = 0; - - for (const compassEvent of compassEvents) { - if ( - !compassEvent.startDate || - !compassEvent.endDate || - !compassEvent.user - ) { - continue; - } - - const gEvent = await _createGcal( - userId, - compassEvent as unknown as Schema_Event_Core, - ); - const gEventId = gEvent.id; - - if (!gEventId) { - continue; - } - - await mongoService.event.updateOne( - { _id: compassEvent._id, user: userId }, - { $set: { gEventId } }, - ); - - syncedCount += 1; - - if (!compassEvent.recurrence?.rule) { - continue; - } - - const instances = await mongoService.event - .find({ - user: userId, - "recurrence.eventId": compassEvent._id.toString(), - }) - .sort({ startDate: 1 }) - .toArray(); - - for (const instance of instances) { - const providerData = MapEvent.toGcalInstanceProviderData( - { - ...instance, - _id: instance._id.toString(), - } as Parameters[0], - { - ...compassEvent, - _id: compassEvent._id.toString(), - gEventId, - } as Parameters[1], - ); - - await mongoService.event.updateOne( - { _id: instance._id, user: userId }, - { $set: providerData }, - ); - } - } - - return syncedCount; -}; - -export default new SyncService(); diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index 29253c27f..6a8607f51 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -8,7 +8,7 @@ import { setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; -import syncService from "@backend/sync/services/sync.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- mock factory spreads requireActual @@ -121,7 +121,7 @@ describe("UserMetadataService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); const metadata = await driver.fetchUserMetadata(userId); @@ -150,7 +150,7 @@ describe("UserMetadataService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") .mockResolvedValue(); await driver.updateUserMetadata({ diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index 6022a850d..20d6a94f6 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -19,7 +19,8 @@ import * as supertokensMiddleware from "@backend/common/middleware/supertokens.m import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; import priorityService from "@backend/priority/services/priority.service"; -import syncService from "@backend/sync/services/sync.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { type Summary_Delete } from "@backend/user/types/user.types"; @@ -258,7 +259,7 @@ describe("UserService", () => { await priorityService.createDefaultPriorities(userId); await SyncDriver.createSync(storedUser!, true); - await syncService.startGoogleCalendarSync(userId); + await googleSyncLifecycleService.startGoogleCalendarSync(userId); const summary: Summary_Delete = await userService.deleteCompassDataForUser(userId, false); @@ -535,7 +536,7 @@ describe("UserService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - await syncService.startGoogleCalendarSync(userId); + await googleSyncLifecycleService.startGoogleCalendarSync(userId); const listCalendarsForUser = calendarService.getByUser.bind(calendarService); @@ -574,7 +575,7 @@ describe("UserService", () => { it("skips Google metadata updates for email/password-only users", async () => { const user = await UserDriver.createUser({ withGoogle: false }); const stopWatchesSpy = jest - .spyOn(syncService, "stopWatches") + .spyOn(syncChannelService, "stopWatches") .mockResolvedValue([]); const updateMetadataSpy = jest.spyOn( userMetadataService, @@ -592,7 +593,7 @@ describe("UserService", () => { it("updates Google metadata and stops watches for last active Google sessions", async () => { const user = await UserDriver.createUser(); const stopWatchesSpy = jest - .spyOn(syncService, "stopWatches") + .spyOn(syncChannelService, "stopWatches") .mockResolvedValue([]); const updateMetadataSpy = jest .spyOn(userMetadataService, "updateUserMetadata") @@ -642,12 +643,15 @@ describe("UserService", () => { it("stops sync, clears the Google refresh token, and resets sync metadata", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - const stopWatchesSpy = jest.spyOn(syncService, "stopWatches"); - const deleteWatchesSpy = jest.spyOn(syncService, "deleteWatchesByUser"); + const stopWatchesSpy = jest.spyOn(syncChannelService, "stopWatches"); + const deleteWatchesSpy = jest.spyOn( + syncChannelService, + "deleteWatchesByUser", + ); expect(user.google).toBeDefined(); - await syncService.startGoogleCalendarSync(userId); + await googleSyncLifecycleService.startGoogleCalendarSync(userId); const eventCountBefore = await mongoService.event.countDocuments({ user: userId, diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index 4a1791c15..a5c2f481a 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -20,7 +20,8 @@ import { normalizeEmail } from "@backend/common/helpers/email.util"; import mongoService from "@backend/common/services/mongo.service"; import eventService from "@backend/event/services/event.service"; import priorityService from "@backend/priority/services/priority.service"; -import syncService from "@backend/sync/services/sync.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import syncRecords from "@backend/sync/services/records/sync.records"; import { findCanonicalCompassUser } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { @@ -198,7 +199,7 @@ class UserService { summary.events = events.deletedCount; if (gcalAccess) { - const watches = await syncService.stopWatches( + const watches = await syncChannelService.stopWatches( userId, undefined, new ObjectId().toString(), @@ -213,13 +214,13 @@ class UserService { summary.eventWatches = watches.deletedCount; } - const syncs = await syncService.deleteAllByUser(userId, session); + const syncs = await syncRecords.deleteAllByUser(userId, session); summary.syncs = syncs.deletedCount; if (user) { // delete other users sync with same Google calendar ID (email) const gCalId = user.email; - const staleSyncs = await syncService.deleteAllByGcalId(gCalId, session); + const staleSyncs = await syncRecords.deleteAllByGcalId(gCalId, session); summary.syncs += staleSyncs.deletedCount; } @@ -262,11 +263,11 @@ class UserService { await eventService.deleteByIntegration("google", userId); if (skipGoogleWatchStop) { - await syncService.deleteWatchesByUser(userId); + await syncChannelService.deleteWatchesByUser(userId); } else { - await syncService.stopWatches(userId); + await syncChannelService.stopWatches(userId); } - await syncService.deleteByIntegration("google", userId); + await syncRecords.deleteByIntegration("google", userId); }; handleLogoutCleanup = async ( @@ -293,7 +294,7 @@ class UserService { } if (options.isLastActiveSession) { - await syncService.stopWatches(userId); + await syncChannelService.stopWatches(userId); } }; diff --git a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts index 24cd47f2d..0add490ef 100644 --- a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts +++ b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts @@ -9,7 +9,7 @@ import { getGcalClient } from "@backend/auth/services/google/clients/google.cale import { MONGO_BATCH_SIZE } from "@backend/common/constants/backend.constants"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import syncService from "@backend/sync/services/sync.service"; +import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; import { getChannelExpiration } from "@backend/sync/util/sync.util"; export default class Migration implements RunnableMigration { @@ -76,7 +76,7 @@ export default class Migration implements RunnableMigration { await Promise.allSettled([ ...syncDocs.map(async (s) => { - await syncService + await syncChannelService .stopWatch(syncDoc.user, s.channelId, s.resourceId, gcal, quotaUser) .catch(logger.error); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts new file mode 100644 index 000000000..a21522fef --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts @@ -0,0 +1,61 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import dayjs from "@core/util/date/dayjs"; +import { + getDraggedEventDateRange, + getIsValidResizeMovement, +} from "./draft.movement"; +import { describe, expect, it } from "bun:test"; + +describe("draft movement helpers", () => { + it("keeps timed drag ranges within the same day when the end would overflow", () => { + const start = dayjs("2024-01-15T23:45:00.000"); + + const result = getDraggedEventDateRange({ + eventStart: start, + durationMin: 60, + isAllDay: false, + }); + + expect(dayjs(result.startDate).format("HH:mm")).toBe("23:00"); + expect(dayjs(result.endDate).format("HH:mm")).toBe("00:00"); + }); + + it("formats all-day drag ranges as date-only values", () => { + const start = dayjs("2024-01-15T09:00:00.000Z"); + + const result = getDraggedEventDateRange({ + eventStart: start, + durationMin: 1440, + isAllDay: true, + }); + + expect(result.startDate).toBe(start.format(YEAR_MONTH_DAY_FORMAT)); + expect(result.endDate).toBe( + start.add(1440, "minutes").format(YEAR_MONTH_DAY_FORMAT), + ); + }); + + it("rejects resize movement that changes a timed event to another day", () => { + expect( + getIsValidResizeMovement({ + currTime: dayjs("2024-01-16T10:00:00.000Z"), + draftStartDate: "2024-01-15T09:00:00.000Z", + currentValue: "2024-01-15T10:00:00.000Z", + dateBeingChanged: "endDate", + isAllDay: false, + }), + ).toBe(false); + }); + + it("accepts all-day resize movement across dates", () => { + expect( + getIsValidResizeMovement({ + currTime: dayjs("2024-01-16T00:00:00.000Z"), + draftStartDate: "2024-01-15", + currentValue: "2024-01-15", + dateBeingChanged: "endDate", + isAllDay: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts new file mode 100644 index 000000000..0391daca4 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts @@ -0,0 +1,58 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; + +interface Params_GetDraggedEventDateRange { + eventStart: Dayjs; + durationMin: number; + isAllDay: boolean; +} + +export const getDraggedEventDateRange = ({ + eventStart, + durationMin, + isAllDay, +}: Params_GetDraggedEventDateRange) => { + let adjustedStart = eventStart; + let adjustedEnd = eventStart.add(durationMin, "minutes"); + + if (!isAllDay && adjustedEnd.date() !== adjustedStart.date()) { + adjustedEnd = adjustedEnd.hour(0).minute(0); + adjustedStart = adjustedEnd.subtract(durationMin, "minutes"); + } + + return { + startDate: isAllDay + ? adjustedStart.format(YEAR_MONTH_DAY_FORMAT) + : adjustedStart.format(), + endDate: isAllDay + ? adjustedEnd.format(YEAR_MONTH_DAY_FORMAT) + : adjustedEnd.format(), + }; +}; + +interface Params_GetIsValidResizeMovement { + currTime: Dayjs; + draftStartDate: string; + currentValue?: string; + dateBeingChanged: "startDate" | "endDate" | null; + isAllDay: boolean; +} + +export const getIsValidResizeMovement = ({ + currTime, + draftStartDate, + currentValue, + dateBeingChanged, + isAllDay, +}: Params_GetIsValidResizeMovement) => { + if (!dateBeingChanged) return false; + if (isAllDay) return true; + + const formatted = currTime.format(); + if (currentValue === formatted) return false; + + const isDifferentDay = currTime.day() !== dayjs(draftStartDate).day(); + if (isDifferentDay) return false; + + return formatted !== draftStartDate; +}; diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts new file mode 100644 index 000000000..cd14dca2a --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts @@ -0,0 +1,59 @@ +import { getDraftSubmitAction } from "./draft.submit-decision"; +import { describe, expect, it } from "bun:test"; + +describe("getDraftSubmitAction", () => { + it("creates a new event when the draft has no id", () => { + expect( + getDraftSubmitAction({ + draft: { title: "New" }, + pendingEventIds: [], + isFormOpenBeforeDragging: false, + isDirty: false, + }), + ).toBe("CREATE"); + }); + + it("discards a pending event update", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "pending-id", title: "Pending" }, + pendingEventIds: ["pending-id"], + isFormOpenBeforeDragging: false, + isDirty: true, + }), + ).toBe("DISCARD"); + }); + + it("opens the form again after a drag that started from an open form", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "event-id", title: "Existing" }, + pendingEventIds: [], + isFormOpenBeforeDragging: true, + isDirty: true, + }), + ).toBe("OPEN_FORM"); + }); + + it("discards unchanged existing events", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "event-id", title: "Existing" }, + pendingEventIds: [], + isFormOpenBeforeDragging: false, + isDirty: false, + }), + ).toBe("DISCARD"); + }); + + it("updates changed existing events", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "event-id", title: "Existing" }, + pendingEventIds: [], + isFormOpenBeforeDragging: false, + isDirty: true, + }), + ).toBe("UPDATE"); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts new file mode 100644 index 000000000..59520463b --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts @@ -0,0 +1,25 @@ +export type DraftSubmitAction = "CREATE" | "DISCARD" | "OPEN_FORM" | "UPDATE"; + +type DraftIdentity = { + _id?: string | null; +}; + +interface Params_GetDraftSubmitAction { + draft: DraftIdentity; + pendingEventIds: string[]; + isFormOpenBeforeDragging: boolean | null; + isDirty: boolean; +} + +export const getDraftSubmitAction = ({ + draft, + pendingEventIds, + isFormOpenBeforeDragging, + isDirty, +}: Params_GetDraftSubmitAction): DraftSubmitAction => { + if (!draft._id) return "CREATE"; + if (pendingEventIds.includes(draft._id)) return "DISCARD"; + if (isFormOpenBeforeDragging) return "OPEN_FORM"; + if (!isDirty) return "DISCARD"; + return "UPDATE"; +}; diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts index 5959e0738..0c730c1e4 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts @@ -29,9 +29,11 @@ mock.module("@web/common/validators/grid.event.validator", () => ({ mock.module("@web/common/validators/someday.event.validator", () => ({ validateSomedayEvent, + validateSomedayEvents: mock((events: Schema_SomedayEvent[]) => events), })); mock.module("@web/common/utils/event/event.util", () => ({ + assembleDefaultEvent: mock(async () => createMockGridEvent()), assembleGridEvent, })); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts index d238fd651..e8786a95c 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts @@ -52,6 +52,11 @@ import { import { type DateCalcs } from "@web/views/Calendar/hooks/grid/useDateCalcs"; import { type WeekProps } from "@web/views/Calendar/hooks/useWeek"; import { GRID_TIME_STEP } from "@web/views/Calendar/layout.constants"; +import { + getDraggedEventDateRange, + getIsValidResizeMovement, +} from "./draft.movement"; +import { getDraftSubmitAction } from "./draft.submit-decision"; import { getDragDurationMinutes } from "./drag-duration.util"; export const useDraftActions = ( @@ -236,31 +241,16 @@ export const useDraftActions = ( const determineSubmitAction = useCallback( (draft: Schema_WebEvent) => { - const isExisting = !!draft._id; - if (!isExisting) return "CREATE"; - - if (isExisting) { - // Prevent updates if event is pending (waiting for backend confirmation) - const isPending = draft._id - ? pendingEventIds.includes(draft._id) - : false; - if (isPending) { - // Event is pending, discard the change and return to original position - return "DISCARD"; - } - - if (isFormOpenBeforeDragging) { - return "OPEN_FORM"; - } - const isSame = reduxDraft - ? !DirtyParser.isEventDirty(draft, reduxDraft) - : false; - if (isSame) { - // no need to make HTTP request - return "DISCARD"; - } - } - return "UPDATE"; + const isDirty = reduxDraft + ? DirtyParser.isEventDirty(draft, reduxDraft) + : true; + + return getDraftSubmitAction({ + draft, + pendingEventIds, + isFormOpenBeforeDragging, + isDirty, + }); }, [reduxDraft, isFormOpenBeforeDragging, pendingEventIds], ); @@ -390,31 +380,22 @@ export const useDraftActions = ( const y = e.clientY - draft.position.dragOffset.y; - let eventStart = dateCalcs.getDateByXY( + const eventStart = dateCalcs.getDateByXY( x, y, weekProps.component.startOfView, ); - let eventEnd = eventStart.add(startEndDurationMin, "minutes"); - - if (!draft.isAllDay) { - // Edge case: timed events' end times can overflow past midnight at the bottom of the grid. - // Below logic prevents that from occurring. - if (eventEnd.date() !== eventStart.date()) { - eventEnd = eventEnd.hour(0).minute(0); - eventStart = eventEnd.subtract(startEndDurationMin, "minutes"); - } - } + const { startDate, endDate } = getDraggedEventDateRange({ + eventStart, + durationMin: startEndDurationMin, + isAllDay: draft.isAllDay, + }); const _draft: Schema_GridEvent = { ...draft, - startDate: draft.isAllDay - ? eventStart.format(YEAR_MONTH_DAY_FORMAT) - : eventStart.format(), - endDate: draft.isAllDay - ? eventEnd.format(YEAR_MONTH_DAY_FORMAT) - : eventEnd.format(), + startDate, + endDate, priority: draft.priority || Priorities.UNASSIGNED, }; @@ -458,22 +439,13 @@ export const useDraftActions = ( (currTime: dayjs.Dayjs) => { if (!draft || !dateBeingChanged) return false; - if (draft.isAllDay) { - return true; - } - - const _currTime = currTime.format(); - const noChange = draft[dateBeingChanged] === _currTime; - - if (noChange) return false; - - const diffDay = currTime.day() !== dayjs(draft.startDate).day(); - if (diffDay) return false; - - const sameStart = currTime.format() === draft.startDate; - if (sameStart) return false; - - return true; + return getIsValidResizeMovement({ + currTime, + draftStartDate: draft.startDate, + currentValue: draft[dateBeingChanged], + dateBeingChanged, + isAllDay: draft.isAllDay, + }); }, [dateBeingChanged, draft], ); From 7b64bb11cc683ee6433a78d17cf58987ffc324b7 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 10:31:29 -0500 Subject: [PATCH 02/13] Fix Google auth repair with stored refresh token --- .codex/config.toml | 2 + .../google/google.auth.service.test.ts | 80 +++++++++++++++++++ .../services/google/google.auth.service.ts | 67 +++++++++++++--- .../backend/src/user/services/user.service.ts | 38 ++++++--- 4 files changed, 166 insertions(+), 21 deletions(-) diff --git a/.codex/config.toml b/.codex/config.toml index 99c48bcb0..c7530dae4 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,3 +1,5 @@ [features] codex_hooks = true +goals = true + diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index bd597ccaf..8ed73711e 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -207,6 +207,11 @@ describe("GoogleAuthService", () => { }); describe("repairGoogleConnection", () => { + const mockDetermineGoogleAuthMode = + determineGoogleAuthMode as unknown as jest.MockedFunction< + typeof determineGoogleAuthMode + >; + it("relinks Google to the Compass user and schedules a full reimport", async () => { const user = await UserDriver.createUser(); const compassUserId = user._id.toString(); @@ -282,6 +287,81 @@ describe("GoogleAuthService", () => { restartSpy.mockRestore(); }); + + it("repairs sync with the stored refresh token when Google sign-in does not return a new one", async () => { + const user = await UserDriver.createUser(); + const compassUserId = user._id.toString(); + const storedRefreshToken = user.google?.gRefreshToken; + const providerUser = UserDriver.generateGoogleUser({ + email: user.email, + sub: user.google?.googleId, + picture: faker.image.url(), + }); + const restartSpy = jest + .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .mockResolvedValue(); + + mockDetermineGoogleAuthMode.mockResolvedValue({ + authMode: "RECONNECT_REPAIR", + compassUserId, + hasStoredRefreshToken: true, + hasHealthySync: false, + createdNewRecipeUser: false, + }); + + await expect( + googleAuthService.handleGoogleAuth({ + providerUser, + oAuthTokens: { + access_token: faker.internet.jwt(), + }, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + }), + ).resolves.toBeUndefined(); + + const updatedUser = await mongoService.user.findOne({ _id: user._id }); + const metadata = + await userMetadataService.fetchUserMetadata(compassUserId); + + expect(updatedUser?.google?.gRefreshToken).toBe(storedRefreshToken); + expect(updatedUser?.google?.picture).toBe(providerUser.picture); + expect(metadata.sync?.importGCal).toBe("RESTART"); + expect(metadata.sync?.incrementalGCalSync).toBe("RESTART"); + expect(restartSpy).toHaveBeenCalledWith(compassUserId); + + restartSpy.mockRestore(); + }); + }); + + describe("googleSignin", () => { + it("preserves the stored refresh token when Google does not return a new one", async () => { + const user = await UserDriver.createUser(); + const compassUserId = user._id.toString(); + const storedRefreshToken = user.google?.gRefreshToken; + const providerUser = UserDriver.generateGoogleUser({ + sub: user.google?.googleId, + picture: faker.image.url(), + }); + const importSpy = jest + .spyOn(googleSyncLifecycleService, "importIncremental") + .mockResolvedValue(undefined); + + await expect( + googleAuthService.googleSignin(providerUser, { + access_token: faker.internet.jwt(), + }), + ).resolves.toEqual({ cUserId: compassUserId }); + + const updatedUser = await mongoService.user.findOne({ _id: user._id }); + + expect(updatedUser?.google?.gRefreshToken).toBe(storedRefreshToken); + expect(updatedUser?.google?.picture).toBe(providerUser.picture); + expect(importSpy).toHaveBeenCalledWith(compassUserId, expect.any(Object)); + + importSpy.mockRestore(); + }); }); describe("connectGoogleToCurrentUser", () => { diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index fffb54d4c..ffcfab064 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -46,6 +46,38 @@ class GoogleAuthService { return { cUserId: compassUserId }; }; + private persistStoredGoogleConnection = async ( + compassUserId: string, + gUser: TokenPayload, + ) => { + const cUserId = zObjectId.parse(compassUserId).toString(); + StringV4Schema.parse(gUser.sub, { + error: () => "Invalid Google user ID", + }); + + const existingUser = await findCompassUserBy("_id", cUserId); + + if (!existingUser?.google?.gRefreshToken) { + throw error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); + } + + await userService.refreshGoogleProfile(cUserId, gUser); + + await userMetadataService.updateUserMetadata({ + userId: cUserId, + data: { + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + }, + }); + + this.restartGoogleCalendarSyncInBackground(cUserId); + + return { cUserId }; + }; + private restartGoogleCalendarSyncInBackground = (cUserId: string) => { googleSyncLifecycleService .restartGoogleCalendarSync(cUserId) @@ -103,6 +135,10 @@ class GoogleAuthService { gUser: TokenPayload, oAuthTokens: Pick, ) { + if (!oAuthTokens.refresh_token) { + return this.persistStoredGoogleConnection(compassUserId, gUser); + } + const { cUserId, gUser: validatedGUser, @@ -168,19 +204,23 @@ class GoogleAuthService { const gUserId = StringV4Schema.parse(gUser.sub, { error: () => "Invalid Google user ID", }); - const refreshToken = StringV4Schema.parse(oAuthTokens.refresh_token, { - error: () => "Invalid or missing Google refresh token", - }); + const refreshToken = oAuthTokens.refresh_token + ? StringV4Schema.parse(oAuthTokens.refresh_token, { + error: () => "Invalid or missing Google refresh token", + }) + : undefined; + const update: Record = { + "google.picture": gUser.picture || "not provided", + lastLoggedInAt: new Date(), + }; + + if (refreshToken) { + update["google.gRefreshToken"] = refreshToken; + } const user = await mongoService.user.findOneAndUpdate( { "google.googleId": gUserId }, - { - $set: { - "google.gRefreshToken": refreshToken, - "google.picture": gUser.picture || "not provided", - lastLoggedInAt: new Date(), - }, - }, + { $set: update }, { returnDocument: "after" }, ); @@ -263,8 +303,13 @@ class GoogleAuthService { case "RECONNECT_REPAIR": { // User exists but needs repair (missing refresh token or unhealthy sync) + const compassUserId = decision.compassUserId; + if (!compassUserId) { + throw new Error("Compass user ID expected for Google repair"); + } + await this.repairGoogleConnection( - decision.compassUserId!, + compassUserId, providerUser, oAuthTokens, ); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index a5c2f481a..d893da245 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -298,21 +298,24 @@ class UserService { } }; - reconnectGoogleCredentials = async ( + private updateGoogleConnection = async ( cUserId: string, gUser: TokenPayload, - refreshToken: string, + refreshToken?: string, ): Promise> => { + const googleUpdate: Record = { + "google.googleId": gUser.sub ?? "", + "google.picture": gUser.picture ?? "", + lastLoggedInAt: new Date(), + }; + + if (refreshToken !== undefined) { + googleUpdate["google.gRefreshToken"] = refreshToken; + } + const user = await mongoService.user.findOneAndUpdate( { _id: zObjectId.parse(cUserId) }, - { - $set: { - "google.googleId": gUser.sub ?? "", - "google.picture": gUser.picture ?? "", - "google.gRefreshToken": refreshToken, - lastLoggedInAt: new Date(), - }, - }, + { $set: googleUpdate }, { returnDocument: "after" }, ); @@ -320,6 +323,21 @@ class UserService { return user as WithId; }; + reconnectGoogleCredentials = async ( + cUserId: string, + gUser: TokenPayload, + refreshToken: string, + ): Promise> => { + return this.updateGoogleConnection(cUserId, gUser, refreshToken); + }; + + refreshGoogleProfile = async ( + cUserId: string, + gUser: TokenPayload, + ): Promise> => { + return this.updateGoogleConnection(cUserId, gUser); + }; + pruneGoogleData = async (userId: string): Promise => { const _id = zObjectId.parse(userId); await this.stopGoogleCalendarSync(userId, { skipGoogleWatchStop: true }); From ffe4fe5354ebba3d4d731f91b4ca0213c35467c2 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 10:37:43 -0500 Subject: [PATCH 03/13] refactor(backend): use function module for google auth --- .../services/google/google.auth.service.ts | 513 +++++++++--------- 1 file changed, 260 insertions(+), 253 deletions(-) diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index ffcfab064..dc22c8d34 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -22,307 +22,314 @@ import { type GoogleSignInSuccess } from "./google.auth.types"; const logger = Logger("app:auth.google.service"); -class GoogleAuthService { - private persistGoogleConnection = async ( - compassUserId: string, - gUser: TokenPayload, - refreshToken: string, - ) => { - await userService.reconnectGoogleCredentials( - compassUserId, - gUser, - refreshToken, - ); +async function persistGoogleConnection( + compassUserId: string, + gUser: TokenPayload, + refreshToken: string, +) { + await userService.reconnectGoogleCredentials( + compassUserId, + gUser, + refreshToken, + ); + + await userMetadataService.updateUserMetadata({ + userId: compassUserId, + data: { + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + }, + }); + + restartGoogleCalendarSyncInBackground(compassUserId); + + return { cUserId: compassUserId }; +} - await userMetadataService.updateUserMetadata({ - userId: compassUserId, - data: { - sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, - }, - }); +async function persistStoredGoogleConnection( + compassUserId: string, + gUser: TokenPayload, +) { + const cUserId = zObjectId.parse(compassUserId).toString(); + StringV4Schema.parse(gUser.sub, { + error: () => "Invalid Google user ID", + }); + + const existingUser = await findCompassUserBy("_id", cUserId); + + if (!existingUser?.google?.gRefreshToken) { + throw error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); + } - this.restartGoogleCalendarSyncInBackground(compassUserId); + await userService.refreshGoogleProfile(cUserId, gUser); - return { cUserId: compassUserId }; - }; + await userMetadataService.updateUserMetadata({ + userId: cUserId, + data: { + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + }, + }); - private persistStoredGoogleConnection = async ( - compassUserId: string, - gUser: TokenPayload, - ) => { - const cUserId = zObjectId.parse(compassUserId).toString(); - StringV4Schema.parse(gUser.sub, { - error: () => "Invalid Google user ID", - }); + restartGoogleCalendarSyncInBackground(cUserId); - const existingUser = await findCompassUserBy("_id", cUserId); + return { cUserId }; +} - if (!existingUser?.google?.gRefreshToken) { - throw error( - UserError.MissingGoogleRefreshToken, - "User has not connected Google Calendar", +function restartGoogleCalendarSyncInBackground(cUserId: string) { + googleSyncLifecycleService + .restartGoogleCalendarSync(cUserId) + .catch((err) => { + logger.error( + `Something went wrong with starting calendar sync for user ${cUserId}`, + err, ); - } + }); +} - await userService.refreshGoogleProfile(cUserId, gUser); +async function googleSignup( + gUser: TokenPayload, + refreshToken: string, + userId: string, +) { + const session = await mongoService.startSession(); + + const user = await session.withTransaction(async (transactionSession) => { + const cUser = await userService.upsertUserFromAuth( + { + userId, + email: gUser.email ?? "", + name: gUser.name || undefined, + locale: gUser.locale || undefined, + google: { + googleId: gUser.sub ?? "", + picture: gUser.picture || "not provided", + gRefreshToken: refreshToken, + }, + }, + transactionSession, + ); await userMetadataService.updateUserMetadata({ - userId: cUserId, + userId: cUser.user.userId, data: { + skipOnboarding: false, sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, }, }); - this.restartGoogleCalendarSyncInBackground(cUserId); + await EmailService.tagNewUserIfEnabled(cUser.user, cUser.isNewUser); - return { cUserId }; - }; + return { cUserId: cUser.user.userId }; + }); - private restartGoogleCalendarSyncInBackground = (cUserId: string) => { - googleSyncLifecycleService - .restartGoogleCalendarSync(cUserId) - .catch((err) => { - logger.error( - `Something went wrong with starting calendar sync for user ${cUserId}`, - err, - ); - }); - }; + restartGoogleCalendarSyncInBackground(user.cUserId); - async googleSignup( - gUser: TokenPayload, - refreshToken: string, - userId: string, - ) { - const session = await mongoService.startSession(); - - const user = await session.withTransaction(async (transactionSession) => { - const cUser = await userService.upsertUserFromAuth( - { - userId, - email: gUser.email ?? "", - name: gUser.name || undefined, - locale: gUser.locale || undefined, - google: { - googleId: gUser.sub ?? "", - picture: gUser.picture || "not provided", - gRefreshToken: refreshToken, - }, - }, - transactionSession, - ); - - await userMetadataService.updateUserMetadata({ - userId: cUser.user.userId, - data: { - skipOnboarding: false, - sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, - }, - }); + return user; +} - await EmailService.tagNewUserIfEnabled(cUser.user, cUser.isNewUser); +async function repairGoogleConnection( + compassUserId: string, + gUser: TokenPayload, + oAuthTokens: Pick, +) { + if (!oAuthTokens.refresh_token) { + return persistStoredGoogleConnection(compassUserId, gUser); + } - return { cUserId: cUser.user.userId }; - }); + const { + cUserId, + gUser: validatedGUser, + refreshToken, + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); - this.restartGoogleCalendarSyncInBackground(user.cUserId); + return persistGoogleConnection(cUserId, validatedGUser, refreshToken); +} - return user; +async function getConnectedCompassUserId( + googleUserId: string | null | undefined, +): Promise { + if (!googleUserId) { + return null; } - async repairGoogleConnection( - compassUserId: string, - gUser: TokenPayload, - oAuthTokens: Pick, - ) { - if (!oAuthTokens.refresh_token) { - return this.persistStoredGoogleConnection(compassUserId, gUser); - } - - const { - cUserId, - gUser: validatedGUser, - refreshToken, - } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); + const user = await findCompassUserBy("google.googleId", googleUserId); + return user?._id.toString() ?? null; +} - return this.persistGoogleConnection(cUserId, validatedGUser, refreshToken); +async function connectGoogleToCurrentUser( + compassUserId: string, + input: GoogleAuthCodeRequest, +) { + const googleOAuthClient = new GoogleOAuthClient(); + const { gUser, tokens } = await googleOAuthClient.exchangeAuthCode(input); + const { + cUserId, + gUser: validatedGUser, + refreshToken, + } = parseReconnectGoogleParams(compassUserId, gUser, tokens); + const existingCompassUserId = + await googleAuthService.getConnectedCompassUserId(validatedGUser.sub); + + if (existingCompassUserId && existingCompassUserId !== cUserId) { + throw error(AuthError.GoogleAccountAlreadyConnected, "User not connected"); } - async getConnectedCompassUserId( - googleUserId: string | null | undefined, - ): Promise { - if (!googleUserId) { - return null; - } + const currentUser = await findCompassUserBy("_id", cUserId); - const user = await findCompassUserBy("google.googleId", googleUserId); - return user?._id.toString() ?? null; + if (!currentUser) { + throw error(UserError.UserNotFound, "User not connected"); } - async connectGoogleToCurrentUser( - compassUserId: string, - input: GoogleAuthCodeRequest, + if ( + !validatedGUser.email || + normalizeEmail(validatedGUser.email) !== normalizeEmail(currentUser.email) ) { - const googleOAuthClient = new GoogleOAuthClient(); - const { gUser, tokens } = await googleOAuthClient.exchangeAuthCode(input); - const { - cUserId, - gUser: validatedGUser, - refreshToken, - } = parseReconnectGoogleParams(compassUserId, gUser, tokens); - const existingCompassUserId = await this.getConnectedCompassUserId( - validatedGUser.sub, - ); + throw error(AuthError.GoogleConnectEmailMismatch, "User not connected"); + } - if (existingCompassUserId && existingCompassUserId !== cUserId) { - throw error( - AuthError.GoogleAccountAlreadyConnected, - "User not connected", - ); - } + return persistGoogleConnection(cUserId, validatedGUser, refreshToken); +} - const currentUser = await findCompassUserBy("_id", cUserId); +async function googleSignin( + gUser: TokenPayload, + oAuthTokens: Pick, +) { + const gUserId = StringV4Schema.parse(gUser.sub, { + error: () => "Invalid Google user ID", + }); + const refreshToken = oAuthTokens.refresh_token + ? StringV4Schema.parse(oAuthTokens.refresh_token, { + error: () => "Invalid or missing Google refresh token", + }) + : undefined; + const update: Record = { + "google.picture": gUser.picture || "not provided", + lastLoggedInAt: new Date(), + }; - if (!currentUser) { - throw error(UserError.UserNotFound, "User not connected"); - } + if (refreshToken) { + update["google.gRefreshToken"] = refreshToken; + } - if ( - !validatedGUser.email || - normalizeEmail(validatedGUser.email) !== normalizeEmail(currentUser.email) - ) { - throw error(AuthError.GoogleConnectEmailMismatch, "User not connected"); - } + const user = await mongoService.user.findOneAndUpdate( + { "google.googleId": gUserId }, + { $set: update }, + { returnDocument: "after" }, + ); + + const cUserId = zObjectId + .parse(user?._id, { error: () => "Invalid credentials" }) + .toString(); + + const googleOAuthClient = new GoogleOAuthClient(); + googleOAuthClient.oauthClient.setCredentials(oAuthTokens); + + googleSyncLifecycleService + .importIncremental(cUserId, googleOAuthClient.getGcalClient()) + .catch(async (err) => { + if ( + err instanceof Error && + err.message === SyncError.NoSyncToken.description + ) { + logger.info( + `Resyncing google data due to missing sync for user: ${cUserId}`, + ); - return this.persistGoogleConnection(cUserId, validatedGUser, refreshToken); - } + await userMetadataService.updateUserMetadata({ + userId: cUserId, + data: { sync: { importGCal: "RESTART" } }, + }); - async googleSignin( - gUser: TokenPayload, - oAuthTokens: Pick, - ) { - const gUserId = StringV4Schema.parse(gUser.sub, { - error: () => "Invalid Google user ID", + restartGoogleCalendarSyncInBackground(cUserId); + return; + } + + logger.error("Error during incremental sync:", err); }); - const refreshToken = oAuthTokens.refresh_token - ? StringV4Schema.parse(oAuthTokens.refresh_token, { - error: () => "Invalid or missing Google refresh token", - }) - : undefined; - const update: Record = { - "google.picture": gUser.picture || "not provided", - lastLoggedInAt: new Date(), - }; - - if (refreshToken) { - update["google.gRefreshToken"] = refreshToken; - } - const user = await mongoService.user.findOneAndUpdate( - { "google.googleId": gUserId }, - { $set: update }, - { returnDocument: "after" }, - ); + return { cUserId }; +} - const cUserId = zObjectId - .parse(user?._id, { error: () => "Invalid credentials" }) - .toString(); - - const googleOAuthClient = new GoogleOAuthClient(); - googleOAuthClient.oauthClient.setCredentials(oAuthTokens); - - googleSyncLifecycleService - .importIncremental(cUserId, googleOAuthClient.getGcalClient()) - .catch(async (err) => { - if ( - err instanceof Error && - err.message === SyncError.NoSyncToken.description - ) { - logger.info( - `Resyncing google data due to missing sync for user: ${cUserId}`, - ); - - await userMetadataService.updateUserMetadata({ - userId: cUserId, - data: { sync: { importGCal: "RESTART" } }, - }); - - this.restartGoogleCalendarSyncInBackground(cUserId); - return; - } - - logger.error("Error during incremental sync:", err); - }); - - return { cUserId }; +async function handleGoogleAuth(success: GoogleSignInSuccess): Promise { + const { + providerUser, + oAuthTokens, + createdNewRecipeUser, + recipeUserId, + loginMethodsLength, + } = success; + + const googleUserId = providerUser.sub; + if (!googleUserId) { + throw new Error("Google user ID (sub) is required"); } - async handleGoogleAuth(success: GoogleSignInSuccess): Promise { - const { - providerUser, - oAuthTokens, - createdNewRecipeUser, - recipeUserId, - loginMethodsLength, - } = success; - - const googleUserId = providerUser.sub; - if (!googleUserId) { - throw new Error("Google user ID (sub) is required"); - } - - // Determine auth mode based on server-side state - const decision = await determineGoogleAuthMode( - googleUserId, - providerUser.email, - createdNewRecipeUser, - ); - - switch (decision.authMode) { - case "SIGNUP": { - const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; - if (!isNewUser) { - // Edge case: no Compass user found but SuperTokens says not new - // This shouldn't happen in normal flow, treat as signup - logger.warn("No Compass user found but isNewUser is false", { - google_user_id: googleUserId, - recipe_user_id: recipeUserId, - created_new_recipe_user: createdNewRecipeUser, - login_methods_length: loginMethodsLength, - }); - } - - const refreshToken = oAuthTokens.refresh_token; - if (!refreshToken) { - throw new Error("Refresh token expected for new user sign-up"); - } - - await this.googleSignup(providerUser, refreshToken, recipeUserId); - return; + // Determine auth mode based on server-side state + const decision = await determineGoogleAuthMode( + googleUserId, + providerUser.email, + createdNewRecipeUser, + ); + + switch (decision.authMode) { + case "SIGNUP": { + const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + if (!isNewUser) { + // Edge case: no Compass user found but SuperTokens says not new + // This shouldn't happen in normal flow, treat as signup + logger.warn("No Compass user found but isNewUser is false", { + google_user_id: googleUserId, + recipe_user_id: recipeUserId, + created_new_recipe_user: createdNewRecipeUser, + login_methods_length: loginMethodsLength, + }); } - case "RECONNECT_REPAIR": { - // User exists but needs repair (missing refresh token or unhealthy sync) - const compassUserId = decision.compassUserId; - if (!compassUserId) { - throw new Error("Compass user ID expected for Google repair"); - } - - await this.repairGoogleConnection( - compassUserId, - providerUser, - oAuthTokens, - ); - return; + const refreshToken = oAuthTokens.refresh_token; + if (!refreshToken) { + throw new Error("Refresh token expected for new user sign-up"); } - case "SIGNIN_INCREMENTAL": { - // Healthy returning user - attempt incremental sync - await this.googleSignin(providerUser, oAuthTokens); - return; + await googleAuthService.googleSignup( + providerUser, + refreshToken, + recipeUserId, + ); + return; + } + + case "RECONNECT_REPAIR": { + // User exists but needs repair (missing refresh token or unhealthy sync) + const compassUserId = decision.compassUserId; + if (!compassUserId) { + throw new Error("Compass user ID expected for Google repair"); } + + await googleAuthService.repairGoogleConnection( + compassUserId, + providerUser, + oAuthTokens, + ); + return; + } + + case "SIGNIN_INCREMENTAL": { + // Healthy returning user - attempt incremental sync + await googleAuthService.googleSignin(providerUser, oAuthTokens); + return; } } } -export default new GoogleAuthService(); +const googleAuthService = { + googleSignup, + repairGoogleConnection, + getConnectedCompassUserId, + connectGoogleToCurrentUser, + googleSignin, + handleGoogleAuth, +}; + +export default googleAuthService; From 2cbc4bf16badf0977700d3b633363f96602e29cb Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 10:41:25 -0500 Subject: [PATCH 04/13] refactor(backend): use function module for google sync lifecycle --- .../google-sync-lifecycle.service.ts | 441 +++++++++--------- 1 file changed, 224 insertions(+), 217 deletions(-) diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts index 0f54bac9f..939be77b4 100644 --- a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts +++ b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts @@ -20,273 +20,280 @@ import userMetadataService from "@backend/user/services/user-metadata.service"; const logger = Logger("app:google-sync-lifecycle.service"); -class GoogleSyncLifecycleService { - private activeFullSyncRestarts = new Set(); - - importFull = async ( - gcal: gCalendar, - gCalendarIds: string[], - userId: string, - ) => { - const session = await mongoService.startSession({ - causalConsistency: true, - }); +const activeFullSyncRestarts = new Set(); + +async function importFull( + gcal: gCalendar, + gCalendarIds: string[], + userId: string, +) { + const session = await mongoService.startSession({ + causalConsistency: true, + }); + + session.startTransaction(); + + try { + const syncImport = await createSyncImport(gcal); + + const eventImports = await Promise.all( + gCalendarIds.map(async (gCalId) => { + const { nextSyncToken, ...result } = await syncImport.importAllEvents( + userId, + gCalId, + 2500, + ); - session.startTransaction(); + await updateSync( + Resource_Sync.EVENTS, + userId, + gCalId, + { nextSyncToken }, + session, + ); - try { - const syncImport = await createSyncImport(gcal); - - const eventImports = await Promise.all( - gCalendarIds.map(async (gCalId) => { - const { nextSyncToken, ...result } = await syncImport.importAllEvents( - userId, - gCalId, - 2500, - ); - - await updateSync( - Resource_Sync.EVENTS, - userId, - gCalId, - { nextSyncToken }, - session, - ); - - return { gCalId, ...result }; - }), - ); + return { gCalId, ...result }; + }), + ); - await session.commitTransaction(); + await session.commitTransaction(); - return eventImports; - } catch (error: unknown) { - await session.abortTransaction(); + return eventImports; + } catch (error: unknown) { + await session.abortTransaction(); - throw error; - } - }; + throw error; + } +} - importIncremental = async ( - userId: string, - gcal?: gCalendar, - perPage = 1000, - ) => { - logger.info( - `Starting incremental Google Calendar sync for user: ${userId}`, +async function importIncremental( + userId: string, + gcal?: gCalendar, + perPage = 1000, +) { + logger.info(`Starting incremental Google Calendar sync for user: ${userId}`); + + try { + sseServer.handleImportGCalStart(userId); + + const userMeta = await userMetadataService.fetchUserMetadata( + userId, + undefined, + { + skipAssessment: true, + }, ); + const proceed = shouldDoIncrementalGCalSync(userMeta); - try { - sseServer.handleImportGCalStart(userId); - - const userMeta = await userMetadataService.fetchUserMetadata( - userId, - undefined, - { - skipAssessment: true, - }, - ); - const proceed = shouldDoIncrementalGCalSync(userMeta); + if (!proceed) { + sseServer.handleImportGCalEnd(userId, { + operation: "INCREMENTAL", + status: "IGNORED", + message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, + }); - if (!proceed) { - sseServer.handleImportGCalEnd(userId, { - operation: "INCREMENTAL", - status: "IGNORED", - message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, - }); + return; + } - return; - } + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "IMPORTING" } }, + }); - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "IMPORTING" } }, - }); + const syncImport = gcal + ? await createSyncImport(gcal) + : await createSyncImport(userId); - const syncImport = gcal - ? await createSyncImport(gcal) - : await createSyncImport(userId); + const result = await syncImport.importLatestEvents(userId, perPage); - const result = await syncImport.importLatestEvents(userId, perPage); + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "COMPLETED" } }, + }); - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "COMPLETED" } }, - }); + sseServer.handleImportGCalEnd(userId, { + operation: "INCREMENTAL", + status: "COMPLETED", + }); + sseServer.handleBackgroundCalendarChange(userId); - sseServer.handleImportGCalEnd(userId, { - operation: "INCREMENTAL", - status: "COMPLETED", - }); - sseServer.handleBackgroundCalendarChange(userId); + return result; + } catch (error) { + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { incrementalGCalSync: "ERRORED" } }, + }); - return result; - } catch (error) { - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { incrementalGCalSync: "ERRORED" } }, - }); + logger.error( + `Incremental Google Calendar sync failed for user: ${userId}`, + error, + ); - logger.error( - `Incremental Google Calendar sync failed for user: ${userId}`, - error, - ); + sseServer.handleImportGCalEnd(userId, { + operation: "INCREMENTAL", + status: "ERRORED", + message: `Incremental Google Calendar sync failed for user: ${userId}`, + }); - sseServer.handleImportGCalEnd(userId, { - operation: "INCREMENTAL", - status: "ERRORED", - message: `Incremental Google Calendar sync failed for user: ${userId}`, - }); + throw error; + } +} - throw error; - } - }; +async function restartGoogleCalendarSync( + userId: string, + options: { force?: boolean } = {}, +) { + const { default: userService } = await import( + "@backend/user/services/user.service" + ); + const isForce = options.force === true; + const operation = isForce ? "REPAIR" : "INCREMENTAL"; + const ignoreMessage = `User ${userId} gcal import is in progress or completed, ignoring this request`; + + if (activeFullSyncRestarts.has(userId)) { + sseServer.handleImportGCalEnd(userId, { + operation, + status: "IGNORED", + message: ignoreMessage, + }); + return; + } - restartGoogleCalendarSync = async ( - userId: string, - options: { force?: boolean } = {}, - ) => { - const { default: userService } = await import( - "@backend/user/services/user.service" - ); - const isForce = options.force === true; - const operation = isForce ? "REPAIR" : "INCREMENTAL"; - const ignoreMessage = `User ${userId} gcal import is in progress or completed, ignoring this request`; + activeFullSyncRestarts.add(userId); - if (this.activeFullSyncRestarts.has(userId)) { + try { + const userMeta = await userService.fetchUserMetadata(userId); + const importStatus = userMeta.sync?.importGCal; + const isImporting = importStatus === "IMPORTING"; + const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); + + if (!proceed) { sseServer.handleImportGCalEnd(userId, { operation, status: "IGNORED", message: ignoreMessage, }); + return; } - this.activeFullSyncRestarts.add(userId); + logger.warn( + `Restarting Google Calendar sync for user: ${userId}${isForce ? " (forced)" : ""}`, + ); + sseServer.handleImportGCalStart(userId); + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "IMPORTING" } }, + }); - try { - const userMeta = await userService.fetchUserMetadata(userId); - const importStatus = userMeta.sync?.importGCal; - const isImporting = importStatus === "IMPORTING"; - const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); + await userService.stopGoogleCalendarSync(userId); + const importResults = + await googleSyncLifecycleService.startGoogleCalendarSync(userId); - if (!proceed) { - sseServer.handleImportGCalEnd(userId, { - operation, - status: "IGNORED", - message: ignoreMessage, - }); + await compassGoogleMirrorService + .syncCompassEventsToGoogle(userId) + .catch((err) => { + logger.error( + `Failed to sync Compass events to Google Calendar for user: ${userId}`, + err, + ); + }); - return; - } + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "COMPLETED" } }, + }); + sseServer.handleImportGCalEnd(userId, { + operation, + status: "COMPLETED", + ...importResults, + }); + sseServer.handleBackgroundCalendarChange(userId); + } catch (err) { + try { + await userService.stopGoogleCalendarSync(userId); + } catch (cleanupError) { + logger.error( + `Failed to clean up partial Google Calendar sync state for user: ${userId}`, + cleanupError, + ); + } + + if (isInvalidGoogleToken(err)) { logger.warn( - `Restarting Google Calendar sync for user: ${userId}${isForce ? " (forced)" : ""}`, + `Google Calendar repair failed because access was revoked for user: ${userId}`, ); - sseServer.handleImportGCalStart(userId); - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "IMPORTING" } }, - }); - await userService.stopGoogleCalendarSync(userId); - const importResults = await this.startGoogleCalendarSync(userId); - - await compassGoogleMirrorService - .syncCompassEventsToGoogle(userId) - .catch((err) => { - logger.error( - `Failed to sync Compass events to Google Calendar for user: ${userId}`, - err, - ); - }); - - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "COMPLETED" } }, - }); + await userService.pruneGoogleData(userId); + sseServer.handleGoogleRevoked(userId); + return; + } - sseServer.handleImportGCalEnd(userId, { - operation, - status: "COMPLETED", - ...importResults, - }); - sseServer.handleBackgroundCalendarChange(userId); - } catch (err) { - try { - await userService.stopGoogleCalendarSync(userId); - } catch (cleanupError) { - logger.error( - `Failed to clean up partial Google Calendar sync state for user: ${userId}`, - cleanupError, - ); - } + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "ERRORED" } }, + }); - if (isInvalidGoogleToken(err)) { - logger.warn( - `Google Calendar repair failed because access was revoked for user: ${userId}`, - ); + logger.error(`Re-sync failed for user: ${userId}`, err); - await userService.pruneGoogleData(userId); - sseServer.handleGoogleRevoked(userId); - return; - } + sseServer.handleImportGCalEnd(userId, { + operation, + status: "ERRORED", + message: getGoogleRepairErrorMessage(err), + }); + } finally { + activeFullSyncRestarts.delete(userId); + } +} - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "ERRORED" } }, - }); +async function startGoogleCalendarSync( + user: string, +): Promise<{ eventsCount: number; calendarsCount: number }> { + const gcal = await getGcalClient(user); - logger.error(`Re-sync failed for user: ${userId}`, err); + const calendarInit = await calendarService.initializeGoogleCalendars( + user, + gcal, + ); - sseServer.handleImportGCalEnd(userId, { - operation, - status: "ERRORED", - message: getGoogleRepairErrorMessage(err), - }); - } finally { - this.activeFullSyncRestarts.delete(userId); - } - }; + const gCalendarIds = calendarInit.googleCalendars + .map(({ id }) => id) + .filter((id): id is string => id !== undefined && id !== null); - startGoogleCalendarSync = async ( - user: string, - ): Promise<{ eventsCount: number; calendarsCount: number }> => { - const gcal = await getGcalClient(user); + const importResults = await googleSyncLifecycleService.importFull( + gcal, + gCalendarIds, + user, + ); - const calendarInit = await calendarService.initializeGoogleCalendars( + if (isUsingGcalWebhookHttps()) { + await syncChannelService.startWatchingGcalResources( user, + [ + { gCalendarId: Resource_Sync.CALENDAR }, + ...gCalendarIds.map((gCalendarId) => ({ gCalendarId })), + ], gcal, ); + } - const gCalendarIds = calendarInit.googleCalendars - .map(({ id }) => id) - .filter((id): id is string => id !== undefined && id !== null); - - const importResults = await this.importFull(gcal, gCalendarIds, user); - - if (isUsingGcalWebhookHttps()) { - await syncChannelService.startWatchingGcalResources( - user, - [ - { gCalendarId: Resource_Sync.CALENDAR }, - ...gCalendarIds.map((gCalendarId) => ({ gCalendarId })), - ], - gcal, - ); - } + const eventsCount = importResults.reduce( + (sum, result) => sum + result.totalChanged, + 0, + ); - const eventsCount = importResults.reduce( - (sum, result) => sum + result.totalChanged, - 0, - ); - - return { - eventsCount, - calendarsCount: gCalendarIds.length, - }; + return { + eventsCount, + calendarsCount: gCalendarIds.length, }; } -export const googleSyncLifecycleService = new GoogleSyncLifecycleService(); +export const googleSyncLifecycleService = { + importFull, + importIncremental, + restartGoogleCalendarSync, + startGoogleCalendarSync, +}; + export default googleSyncLifecycleService; From 3a5221671ed0218064acfd49a7687c996c9e528a Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 10:47:09 -0500 Subject: [PATCH 05/13] refactor(backend): use function modules for sync channels --- .../sync-channel-maintenance.service.ts | 209 +++--- .../services/channel/sync-channel.service.ts | 692 +++++++++--------- 2 files changed, 459 insertions(+), 442 deletions(-) diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts index 1f114dd04..438d877b1 100644 --- a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts +++ b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts @@ -12,31 +12,32 @@ import { findCompassUserBy } from "@backend/user/queries/user.queries"; const logger = Logger("app:sync-channel-maintenance.service"); -class SyncChannelMaintenanceService { - runMaintenance = async () => { - const cursor = mongoService.user.find().batchSize(MONGO_BATCH_SIZE); - const users: ObjectId[] = []; - const result = { - deleted: 0, - refreshed: 0, - ignored: 0, - pruned: 0, - revoked: 0, - resynced: 0, - }; - - for await (const user of cursor) { - users.push(user._id); - } - - const limit = createConcurrencyLimiter(5); - - const run = await Promise.all( - users.map((user) => - limit(() => - this.runMaintenanceByUser(user.toString(), { +async function runMaintenance() { + const cursor = mongoService.user.find().batchSize(MONGO_BATCH_SIZE); + const users: ObjectId[] = []; + const result = { + deleted: 0, + refreshed: 0, + ignored: 0, + pruned: 0, + revoked: 0, + resynced: 0, + }; + + for await (const user of cursor) { + users.push(user._id); + } + + const limit = createConcurrencyLimiter(5); + + const run = await Promise.all( + users.map((user) => + limit(() => + syncChannelMaintenanceService + .runMaintenanceByUser(user.toString(), { log: false, - }).catch((error) => { + }) + .catch((error) => { logger.error( `Error running sync maintenance for user: ${user.toString()}`, error, @@ -49,91 +50,93 @@ class SyncChannelMaintenanceService { ...result, }; }), - ), ), - ); - - const results = run.reduce( - (acc, res) => ({ - deleted: acc.deleted + res.deleted, - refreshed: acc.refreshed + res.refreshed, - ignored: acc.ignored + res.ignored, - pruned: acc.pruned + res.pruned, - revoked: acc.revoked + res.revoked, - resynced: acc.resynced + res.resynced, - }), - result, - ); + ), + ); + + const results = run.reduce( + (acc, res) => ({ + deleted: acc.deleted + res.deleted, + refreshed: acc.refreshed + res.refreshed, + ignored: acc.ignored + res.ignored, + pruned: acc.pruned + res.pruned, + revoked: acc.revoked + res.revoked, + resynced: acc.resynced + res.resynced, + }), + result, + ); + + logger.debug(`Sync Maintenance Results: + IGNORED: ${results.ignored} + PRUNED: ${results.pruned} + REFRESHED: ${results.refreshed} + + DELETED DURING PRUNE: ${results.deleted} + REVOKED SESSION DURING REFRESH: ${results.revoked} + RESYNCED DURING REFRESH: ${results.resynced} + `); + + return results; +} - logger.debug(`Sync Maintenance Results: - IGNORED: ${results.ignored} - PRUNED: ${results.pruned} - REFRESHED: ${results.refreshed} +async function runMaintenanceByUser( + userId: string, + params: { dry?: boolean; log?: boolean } = { log: true }, +) { + const user = await findCompassUserBy("_id", userId); + const maintenance = await prepWatchMaintenanceForUser(userId); + const ignore = [{ user: userId, payload: maintenance.ignore }]; + const prune = [{ user: userId, payload: maintenance.prune }]; + const refresh = [{ user: userId, payload: maintenance.refresh }]; + + const result = { + ignore, + prune, + refresh, + user: user?.email || "Not found", + ignored: 0, + pruned: 0, + refreshed: 0, + revoked: 0, + deleted: 0, + resynced: 0, + }; - DELETED DURING PRUNE: ${results.deleted} - REVOKED SESSION DURING REFRESH: ${results.revoked} - RESYNCED DURING REFRESH: ${results.resynced} - `); + if (params?.dry) return result; - return results; - }; + const pruneResult = await pruneSync(prune); + const pruned = pruneResult.filter((p) => !p.deletedUserData); + const deletedDuringPrune = pruneResult.filter((p) => p.deletedUserData); + const refreshResult = await refreshWatch(refresh); + const refreshed = refreshResult; + const resynced = refreshResult.filter((r) => r.resynced); + + if (params?.log) { + logger.debug(`Sync Maintenance Results: + IGNORED: ${ignore.length} + PRUNED: ${pruned.flatMap((p) => p.results).length} + REFRESHED: ${refreshed.flatMap((r) => r.results.filter((r) => r.success)).length} - runMaintenanceByUser = async ( - userId: string, - params: { dry?: boolean; log?: boolean } = { log: true }, - ) => { - const user = await findCompassUserBy("_id", userId); - const maintenance = await prepWatchMaintenanceForUser(userId); - const ignore = [{ user: userId, payload: maintenance.ignore }]; - const prune = [{ user: userId, payload: maintenance.prune }]; - const refresh = [{ user: userId, payload: maintenance.refresh }]; - - const result = { - ignore, - prune, - refresh, - user: user?.email || "Not found", - ignored: 0, - pruned: 0, - refreshed: 0, - revoked: 0, - deleted: 0, - resynced: 0, - }; - - if (params?.dry) return result; - - const pruneResult = await pruneSync(prune); - const pruned = pruneResult.filter((p) => !p.deletedUserData); - const deletedDuringPrune = pruneResult.filter((p) => p.deletedUserData); - const refreshResult = await refreshWatch(refresh); - const refreshed = refreshResult; - const resynced = refreshResult.filter((r) => r.resynced); - - if (params?.log) { - logger.debug(`Sync Maintenance Results: - IGNORED: ${ignore.length} - PRUNED: ${pruned.flatMap((p) => p.results).length} - REFRESHED: ${refreshed.flatMap((r) => r.results.filter((r) => r.success)).length} - - DELETED DURING PRUNE: ${deletedDuringPrune.map((r) => r.user).toString()} - RESYNCED DURING REFRESH: ${resynced.map((r) => r.user).toString()} - `); - } - - return { - ...result, - ignored: ignore.flatMap(({ payload }) => payload).length, - pruned: pruned.flatMap(({ results }) => results).length, - refreshed: refreshed.flatMap(({ results }) => - results.filter((r) => r.success), - ).length, - deleted: deletedDuringPrune.length, - resynced: resynced.length, - }; + DELETED DURING PRUNE: ${deletedDuringPrune.map((r) => r.user).toString()} + RESYNCED DURING REFRESH: ${resynced.map((r) => r.user).toString()} + `); + } + + return { + ...result, + ignored: ignore.flatMap(({ payload }) => payload).length, + pruned: pruned.flatMap(({ results }) => results).length, + refreshed: refreshed.flatMap(({ results }) => + results.filter((r) => r.success), + ).length, + deleted: deletedDuringPrune.length, + resynced: resynced.length, }; } -export const syncChannelMaintenanceService = - new SyncChannelMaintenanceService(); +export const syncChannelMaintenanceService = { + runMaintenance, + runMaintenanceByUser, +}; + export default syncChannelMaintenanceService; diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.ts b/packages/backend/src/sync/services/channel/sync-channel.service.ts index 212742767..a6f1b8a48 100644 --- a/packages/backend/src/sync/services/channel/sync-channel.service.ts +++ b/packages/backend/src/sync/services/channel/sync-channel.service.ts @@ -37,429 +37,433 @@ import { findCompassUserBy } from "@backend/user/queries/user.queries"; const logger = Logger("app:sync-channel.service"); -class SyncChannelService { - deleteWatchesByUser = async ( - user: string, - session?: ClientSession, - ): Promise => { - const watches = await mongoService.watch - .find({ user }, { session }) - .toArray(); - - await mongoService.watch.deleteMany({ user }, { session }); - - return watches.map(({ _id, resourceId }) => ({ - channelId: _id.toString(), - resourceId, - })); - }; +async function deleteWatchesByUser( + user: string, + session?: ClientSession, +): Promise { + const watches = await mongoService.watch + .find({ user }, { session }) + .toArray(); + + await mongoService.watch.deleteMany({ user }, { session }); + + return watches.map(({ _id, resourceId }) => ({ + channelId: _id.toString(), + resourceId, + })); +} - private prepareStopWatches = async ( - user: string, - gcal?: gCalendar, - session?: ClientSession, - ) => { - const watches = await mongoService.watch - .find({ user }, { session }) - .toArray(); - - if (watches.length === 0 || gcal) { - return { watches, gcal }; - } +async function prepareStopWatches( + user: string, + gcal?: gCalendar, + session?: ClientSession, +) { + const watches = await mongoService.watch + .find({ user }, { session }) + .toArray(); + + if (watches.length === 0 || gcal) { + return { watches, gcal }; + } - const compassUser = await findCompassUserBy("_id", user); + const compassUser = await findCompassUserBy("_id", user); - if (!compassUser) { - throw error(UserError.UserNotFound, "User not found"); - } + if (!compassUser) { + throw error(UserError.UserNotFound, "User not found"); + } - if (!compassUser.google?.gRefreshToken) { - await mongoService.watch.deleteMany({ user }, { session }); + if (!compassUser.google?.gRefreshToken) { + await mongoService.watch.deleteMany({ user }, { session }); - logger.warn( - "Google refresh token is missing. Corresponding watch records deleted", - ); + logger.warn( + "Google refresh token is missing. Corresponding watch records deleted", + ); - return { watches: [], gcal }; - } + return { watches: [], gcal }; + } - return { - watches, - gcal: await getGcalClient(user), - }; + return { + watches, + gcal: await getGcalClient(user), }; +} - async cleanupStaleWatchChannel({ - channelId, +async function cleanupStaleWatchChannel({ + channelId, + resourceId, +}: Payload_Sync_Notif): Promise { + const channel = await mongoService.watch.findOne({ + _id: channelId, resourceId, - }: Payload_Sync_Notif): Promise { - const channel = await mongoService.watch.findOne({ - _id: channelId, - resourceId, - }); + }); - if (!channel) { - logger.warn( - `Ignoring stale Google notification because no exact watch exists for channelId: ${channelId.toString()}, resourceId: ${resourceId}`, - ); + if (!channel) { + logger.warn( + `Ignoring stale Google notification because no exact watch exists for channelId: ${channelId.toString()}, resourceId: ${resourceId}`, + ); - return false; - } + return false; + } - try { - await this.stopWatch( - channel.user, - channel._id.toString(), - channel.resourceId, - ); + try { + await syncChannelService.stopWatch( + channel.user, + channel._id.toString(), + channel.resourceId, + ); - logger.warn( - `Cleaned up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()} with resourceId: ${channel.resourceId}`, - ); + logger.warn( + `Cleaned up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()} with resourceId: ${channel.resourceId}`, + ); - return true; - } catch (error) { - logger.error( - `Failed to clean up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()}`, - error, - ); + return true; + } catch (error) { + logger.error( + `Failed to clean up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()}`, + error, + ); - return false; - } + return false; } +} - handleGcalNotification = async (payload: Payload_Sync_Notif) => { - const { channelId, resourceId, resourceState, resource } = payload; - const { expiration } = payload; - - if (resourceState === XGoogleResourceState.SYNC) { - logger.info( - `${resource} sync initialized for channelId: ${payload.channelId.toString()}`, - ); +async function handleGcalNotification(payload: Payload_Sync_Notif) { + const { channelId, resourceId, resourceState, resource } = payload; + const { expiration } = payload; - return "INITIALIZED"; - } + if (resourceState === XGoogleResourceState.SYNC) { + logger.info( + `${resource} sync initialized for channelId: ${payload.channelId.toString()}`, + ); - const watch = await mongoService.watch.findOne({ - _id: channelId, - resourceId, - expiration: { $gte: expiration }, - }); + return "INITIALIZED"; + } - if (!watch) { - const cleanedUp = await this.cleanupStaleWatchChannel(payload); + const watch = await mongoService.watch.findOne({ + _id: channelId, + resourceId, + expiration: { $gte: expiration }, + }); - if (cleanedUp) return "IGNORED"; + if (!watch) { + const cleanedUp = + await syncChannelService.cleanupStaleWatchChannel(payload); - logger.warn( - `Ignoring notification because no active watch record exists for channel: ${payload.channelId.toString()}`, - ); + if (cleanedUp) return "IGNORED"; - return "IGNORED"; - } + logger.warn( + `Ignoring notification because no active watch record exists for channel: ${payload.channelId.toString()}`, + ); - const sync = await getSync({ userId: watch.user, resource }); + return "IGNORED"; + } - if (!sync) { - const cleanedUp = await this.cleanupStaleWatchChannel(payload); + const sync = await getSync({ userId: watch.user, resource }); - if (cleanedUp) return "IGNORED"; + if (!sync) { + const cleanedUp = + await syncChannelService.cleanupStaleWatchChannel(payload); - logger.warn( - `Ignoring notification because no sync record exists for channel: ${payload.channelId.toString()}`, - ); + if (cleanedUp) return "IGNORED"; - return "IGNORED"; - } + logger.warn( + `Ignoring notification because no sync record exists for channel: ${payload.channelId.toString()}`, + ); - const userId = sync.user; - const { events = [], calendarlist = [] } = sync.google ?? {}; - const channels = [...events, ...calendarlist]; - const channel = channels.find((e) => e.gCalendarId === watch.gCalendarId); - const calendarId = channel?.gCalendarId; - const nextSyncToken = channel?.nextSyncToken; - - if (!nextSyncToken) { - throw error( - SyncError.NoSyncToken, - `Notification not handled because no sync token found for calendarId: ${calendarId}`, - ); - } + return "IGNORED"; + } - const gcal = await getGcalClient(userId); - const handler = new GCalNotificationHandler( - gcal, - resource, - userId, - watch.gCalendarId, - nextSyncToken, + const userId = sync.user; + const { events = [], calendarlist = [] } = sync.google ?? {}; + const channels = [...events, ...calendarlist]; + const channel = channels.find((e) => e.gCalendarId === watch.gCalendarId); + const calendarId = channel?.gCalendarId; + const nextSyncToken = channel?.nextSyncToken; + + if (!nextSyncToken) { + throw error( + SyncError.NoSyncToken, + `Notification not handled because no sync token found for calendarId: ${calendarId}`, ); + } - await handler.handleNotification(); + const gcal = await getGcalClient(userId); + const handler = new GCalNotificationHandler( + gcal, + resource, + userId, + watch.gCalendarId, + nextSyncToken, + ); - sseServer.handleBackgroundCalendarChange(userId); + await handler.handleNotification(); - const result = "PROCESSED"; + sseServer.handleBackgroundCalendarChange(userId); - logger.info( - `GCal Notification for user: ${userId}, calendarId: ${calendarId} ${result}`, - ); + const result = "PROCESSED"; - return result; - }; + logger.info( + `GCal Notification for user: ${userId}, calendarId: ${calendarId} ${result}`, + ); - refreshWatch = async ( - userId: string, - payload: Params_WatchEvents, - gcal?: gCalendar, - ) => { - if (!gcal) gcal = await getGcalClient(userId); + return result; +} - const watchExists = payload.channelId && payload.resourceId; +async function refreshWatch( + userId: string, + payload: Params_WatchEvents, + gcal?: gCalendar, +) { + if (!gcal) gcal = await getGcalClient(userId); - if (watchExists) { - await this.stopWatch(userId, payload.channelId, payload.resourceId, gcal); - } + const watchExists = payload.channelId && payload.resourceId; - const watchResult = await this.startWatchingGcalResources( + if (watchExists) { + await syncChannelService.stopWatch( userId, - [{ gCalendarId: payload.gCalendarId, quotaUser: payload.quotaUser }], + payload.channelId, + payload.resourceId, gcal, ); + } - return watchResult[0]; - }; - - startWatchingGcalCalendars = async ( - user: string, - params: Pick, - gcal: gCalendar, - ): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> => { - try { - const alreadyWatching = await isWatchingGoogleResource( - user, - Resource_Sync.CALENDAR, - ); - - if (alreadyWatching) { - logger.error( - `Skipped Start Watch for ${Resource_Sync.CALENDAR}`, - WatchError.CalendarWatchExists, - ); - - return { acknowledged: false }; - } - - const expiration = getChannelExpiration(); - const _id = new ObjectId(); - const channelId = _id.toString(); - - const { watch: gcalWatch } = await gcalService.watchCalendars(gcal, { - ...params, - channelId, - expiration, - }); - const resourceId = gcalWatch.resourceId; - - if (!resourceId) { - throw error( - GcalError.Unsure, - "Calendar watch response missing resourceId", - ); - } + const watchResult = await syncChannelService.startWatchingGcalResources( + userId, + [{ gCalendarId: payload.gCalendarId, quotaUser: payload.quotaUser }], + gcal, + ); - const watch = await mongoService.watch - .insertOne( - WatchSchema.parse({ - _id, - user, - gCalendarId: Resource_Sync.CALENDAR, - resourceId, - expiration: ExpirationDateSchema.parse(gcalWatch.expiration), - createdAt: new Date(), - }), - ) - .catch(async (error) => { - await this.stopWatch(user, channelId, resourceId, gcal); + return watchResult[0]; +} - throw error; - }); +async function startWatchingGcalCalendars( + user: string, + params: Pick, + gcal: gCalendar, +): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> { + try { + const alreadyWatching = await isWatchingGoogleResource( + user, + Resource_Sync.CALENDAR, + ); - return watch; - } catch (err) { - logger.error(`Error starting calendar watch for user: ${user}`, err); + if (alreadyWatching) { + logger.error( + `Skipped Start Watch for ${Resource_Sync.CALENDAR}`, + WatchError.CalendarWatchExists, + ); return { acknowledged: false }; } - }; - startWatchingGcalEvents = async ( - user: string, - params: Pick, - gcal: gCalendar, - ): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> => { - try { - const alreadyWatching = await isWatchingGoogleResource( - user, - params.gCalendarId, - ); + const expiration = getChannelExpiration(); + const _id = new ObjectId(); + const channelId = _id.toString(); - if (alreadyWatching) { - logger.error( - `Skipped Start Watch for ${params.gCalendarId} ${Resource_Sync.EVENTS}`, - WatchError.EventWatchExists, - ); + const { watch: gcalWatch } = await gcalService.watchCalendars(gcal, { + ...params, + channelId, + expiration, + }); + const resourceId = gcalWatch.resourceId; - return { acknowledged: false }; - } + if (!resourceId) { + throw error(GcalError.Unsure, "Calendar watch response missing resourceId"); + } - const expiration = getChannelExpiration(); - const _id = new ObjectId(); - const channelId = _id.toString(); + const watch = await mongoService.watch + .insertOne( + WatchSchema.parse({ + _id, + user, + gCalendarId: Resource_Sync.CALENDAR, + resourceId, + expiration: ExpirationDateSchema.parse(gcalWatch.expiration), + createdAt: new Date(), + }), + ) + .catch(async (error) => { + await syncChannelService.stopWatch(user, channelId, resourceId, gcal); - const { watch: gcalWatch } = await gcalService.watchEvents(gcal, { - ...params, - channelId, - expiration, + throw error; }); - const resourceId = gcalWatch.resourceId; - if (!resourceId) { - throw error( - GcalError.Unsure, - "Event watch response missing resourceId", - ); - } + return watch; + } catch (err) { + logger.error(`Error starting calendar watch for user: ${user}`, err); - const watch = await mongoService.watch - .insertOne( - WatchSchema.parse({ - _id, - user, - gCalendarId: params.gCalendarId, - resourceId, - expiration: ExpirationDateSchema.parse(gcalWatch.expiration), - createdAt: new Date(), - }), - ) - .catch(async (error) => { - await this.stopWatch(user, channelId, resourceId, gcal); + return { acknowledged: false }; + } +} - throw error; - }); +async function startWatchingGcalEvents( + user: string, + params: Pick, + gcal: gCalendar, +): Promise<{ acknowledged: boolean; insertedId?: ObjectId }> { + try { + const alreadyWatching = await isWatchingGoogleResource( + user, + params.gCalendarId, + ); - return watch; - } catch (err) { - logger.error(`Error starting events watch for user: ${user}`, err); + if (alreadyWatching) { + logger.error( + `Skipped Start Watch for ${params.gCalendarId} ${Resource_Sync.EVENTS}`, + WatchError.EventWatchExists, + ); return { acknowledged: false }; } - }; - startWatchingGcalResources = async ( - userId: string, - watchParams: Pick[], - gcal: gCalendar, - ) => { - if (!isUsingGcalWebhookHttps()) { - return []; - } + const expiration = getChannelExpiration(); + const _id = new ObjectId(); + const channelId = _id.toString(); - return Promise.all( - watchParams.map(async (params) => { - if (params.gCalendarId === (Resource_Sync.CALENDAR as string)) { - return this.startWatchingGcalCalendars(userId, params, gcal); - } + const { watch: gcalWatch } = await gcalService.watchEvents(gcal, { + ...params, + channelId, + expiration, + }); + const resourceId = gcalWatch.resourceId; - return this.startWatchingGcalEvents(userId, params, gcal); - }), - ).then((results) => results.filter((r) => r !== undefined)); - }; + if (!resourceId) { + throw error(GcalError.Unsure, "Event watch response missing resourceId"); + } - stopWatch = async ( - user: string, - channelId: string, - resourceId: string, - gcal?: gCalendar, - quotaUser?: string, - session?: ClientSession, - ) => { - const filter = { user, _id: new ObjectId(channelId), resourceId }; - - try { - if (!gcal) gcal = await getGcalClient(user); - - await gcalService.stopWatch(gcal, { - quotaUser, - channelId, - resourceId, + const watch = await mongoService.watch + .insertOne( + WatchSchema.parse({ + _id, + user, + gCalendarId: params.gCalendarId, + resourceId, + expiration: ExpirationDateSchema.parse(gcalWatch.expiration), + createdAt: new Date(), + }), + ) + .catch(async (error) => { + await syncChannelService.stopWatch(user, channelId, resourceId, gcal); + + throw error; }); - await mongoService.watch.deleteOne(filter, { session }); + return watch; + } catch (err) { + logger.error(`Error starting events watch for user: ${user}`, err); - return { channelId, resourceId }; - } catch (e) { - const status = getGoogleErrorStatus(e); + return { acknowledged: false }; + } +} - if (status === 404) { - await mongoService.watch.deleteOne(filter, { session }); +async function startWatchingGcalResources( + userId: string, + watchParams: Pick[], + gcal: gCalendar, +) { + if (!isUsingGcalWebhookHttps()) { + return []; + } - logger.warn( - "Channel no longer exists. Corresponding sync record deleted", + return Promise.all( + watchParams.map(async (params) => { + if (params.gCalendarId === (Resource_Sync.CALENDAR as string)) { + return syncChannelService.startWatchingGcalCalendars( + userId, + params, + gcal, ); - - return undefined; } - if (status === 401 || isInvalidGoogleToken(e)) { - await mongoService.watch.deleteOne(filter, { session }); + return syncChannelService.startWatchingGcalEvents(userId, params, gcal); + }), + ).then((results) => results.filter((r) => r !== undefined)); +} - logger.warn( - "Google authorization is no longer valid. Corresponding sync record deleted", - ); +async function stopWatch( + user: string, + channelId: string, + resourceId: string, + gcal?: gCalendar, + quotaUser?: string, + session?: ClientSession, +) { + const filter = { user, _id: new ObjectId(channelId), resourceId }; + + try { + if (!gcal) gcal = await getGcalClient(user); + + await gcalService.stopWatch(gcal, { + quotaUser, + channelId, + resourceId, + }); - return undefined; - } + await mongoService.watch.deleteOne(filter, { session }); - if (isMissingGoogleRefreshToken(e)) { - await mongoService.watch.deleteOne(filter, { session }); + return { channelId, resourceId }; + } catch (e) { + const status = getGoogleErrorStatus(e); - logger.warn( - "Google refresh token is missing. Corresponding watch record deleted", - ); + if (status === 404) { + await mongoService.watch.deleteOne(filter, { session }); - return undefined; - } + logger.warn("Channel no longer exists. Corresponding sync record deleted"); - throw e; + return undefined; } - }; - stopWatches = async ( - user: string, - gcal?: gCalendar, - quotaUser?: string, - session?: ClientSession, - ): Promise => { - const prepared = await this.prepareStopWatches(user, gcal, session); + if (status === 401 || isInvalidGoogleToken(e)) { + await mongoService.watch.deleteOne(filter, { session }); - if (prepared.watches.length === 0) { - return []; + logger.warn( + "Google authorization is no longer valid. Corresponding sync record deleted", + ); + + return undefined; } - logger.debug( - `Stopping ${prepared.watches.length} gcal event watches for user: ${user}`, - ); - const result = await Promise.all( - prepared.watches.map(async ({ _id, resourceId }) => - this.stopWatch( + if (isMissingGoogleRefreshToken(e)) { + await mongoService.watch.deleteOne(filter, { session }); + + logger.warn( + "Google refresh token is missing. Corresponding watch record deleted", + ); + + return undefined; + } + + throw e; + } +} + +async function stopWatches( + user: string, + gcal?: gCalendar, + quotaUser?: string, + session?: ClientSession, +): Promise { + const prepared = await prepareStopWatches(user, gcal, session); + + if (prepared.watches.length === 0) { + return []; + } + + logger.debug( + `Stopping ${prepared.watches.length} gcal event watches for user: ${user}`, + ); + const result = await Promise.all( + prepared.watches.map(async ({ _id, resourceId }) => + syncChannelService + .stopWatch( user, _id.toString(), resourceId, prepared.gcal, quotaUser, session, - ).catch((error) => { + ) + .catch((error) => { logger.error( `Error stopping watch for user: ${user}, channelId: ${_id.toString()}`, error, @@ -467,17 +471,27 @@ class SyncChannelService { return undefined; }), - ), - ); + ), + ); - const stopped = result.filter( - (identity): identity is { channelId: string; resourceId: string } => - identity !== undefined, - ); + const stopped = result.filter( + (identity): identity is { channelId: string; resourceId: string } => + identity !== undefined, + ); - return stopped; - }; + return stopped; } -export const syncChannelService = new SyncChannelService(); +export const syncChannelService = { + deleteWatchesByUser, + cleanupStaleWatchChannel, + handleGcalNotification, + refreshWatch, + startWatchingGcalCalendars, + startWatchingGcalEvents, + startWatchingGcalResources, + stopWatch, + stopWatches, +}; + export default syncChannelService; From d4580e0700e01608c3b5d37d541a3a2b24ce958e Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 10:57:14 -0500 Subject: [PATCH 06/13] refactor(backend): use named google sync service exports --- .../src/__tests__/drivers/sync.driver.ts | 2 +- .../auth/controllers/auth.controller.test.ts | 2 +- .../src/auth/controllers/auth.controller.ts | 2 +- .../google/google.auth.service.test.ts | 6 +++--- .../services/google/google.auth.service.ts | 20 ++++++++----------- .../google.auth.success.service.test.ts | 2 +- .../errors/handlers/error.express.handler.ts | 2 +- .../supertokens.middleware.handlers.ts | 2 +- .../middleware/supertokens.middleware.test.ts | 4 ++-- .../sync/controllers/sync.controller.test.ts | 4 ++-- .../src/sync/controllers/sync.controller.ts | 6 +++--- .../sync/controllers/sync.debug.controller.ts | 6 +++--- .../sync-channel-maintenance.service.test.ts | 2 +- .../sync-channel-maintenance.service.ts | 2 -- .../channel/sync-channel.service.test.ts | 2 +- .../services/channel/sync-channel.service.ts | 11 ++++++---- .../src/sync/services/import/sync.import.ts | 2 +- .../google-sync-lifecycle.service.test.ts | 11 ++++------ .../google-sync-lifecycle.service.ts | 4 +--- .../services/maintain/sync.maintenance.ts | 4 ++-- .../services/user-metadata.service.test.ts | 2 +- .../src/user/services/user.service.test.ts | 4 ++-- .../backend/src/user/services/user.service.ts | 2 +- ....10.13T14.22.21.migrate-sync-watch-data.ts | 2 +- 24 files changed, 49 insertions(+), 57 deletions(-) diff --git a/packages/backend/src/__tests__/drivers/sync.driver.ts b/packages/backend/src/__tests__/drivers/sync.driver.ts index 605636c03..85da28b99 100644 --- a/packages/backend/src/__tests__/drivers/sync.driver.ts +++ b/packages/backend/src/__tests__/drivers/sync.driver.ts @@ -6,7 +6,7 @@ import dayjs from "@core/util/date/dayjs"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import mongoService from "@backend/common/services/mongo.service"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import { updateSync } from "@backend/sync/util/sync.queries"; export class SyncDriver { diff --git a/packages/backend/src/auth/controllers/auth.controller.test.ts b/packages/backend/src/auth/controllers/auth.controller.test.ts index 717cb3b5c..3e483157d 100644 --- a/packages/backend/src/auth/controllers/auth.controller.test.ts +++ b/packages/backend/src/auth/controllers/auth.controller.test.ts @@ -8,7 +8,7 @@ import authController from "./auth.controller"; jest.mock("@backend/auth/services/google/google.auth.service", () => ({ __esModule: true, - default: { + googleAuthService: { connectGoogleToCurrentUser: jest.fn(), }, })); diff --git a/packages/backend/src/auth/controllers/auth.controller.ts b/packages/backend/src/auth/controllers/auth.controller.ts index 06051e01e..19e6db2d5 100644 --- a/packages/backend/src/auth/controllers/auth.controller.ts +++ b/packages/backend/src/auth/controllers/auth.controller.ts @@ -7,7 +7,7 @@ import { } from "@core/types/auth.types"; import { zObjectId } from "@core/types/type.utils"; import compassAuthService from "@backend/auth/services/compass/compass.auth.service"; -import googleAuthService from "@backend/auth/services/google/google.auth.service"; +import { googleAuthService } from "@backend/auth/services/google/google.auth.service"; import { ENV, isGoogleConfigured, diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index 8ed73711e..97abd092f 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -12,10 +12,10 @@ import { determineGoogleAuthMode } from "@backend/auth/services/google/util/goog import { AuthError } from "@backend/common/errors/auth/auth.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; -import googleAuthService from "./google.auth.service"; +import { googleAuthService } from "./google.auth.service"; import { type AuthDecision, type GoogleSignInSuccess, @@ -32,7 +32,7 @@ jest.mock("@backend/auth/services/google/util/google.auth.util", () => { }; }); -describe("GoogleAuthService", () => { +describe("googleAuthService", () => { beforeEach(setupTestDb); beforeEach(cleanupCollections); afterAll(cleanupTestDb); diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index dc22c8d34..4e3fdbded 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -14,7 +14,7 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { normalizeEmail } from "@backend/common/helpers/email.util"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -78,14 +78,12 @@ async function persistStoredGoogleConnection( } function restartGoogleCalendarSyncInBackground(cUserId: string) { - googleSyncLifecycleService - .restartGoogleCalendarSync(cUserId) - .catch((err) => { - logger.error( - `Something went wrong with starting calendar sync for user ${cUserId}`, - err, - ); - }); + googleSyncLifecycleService.restartGoogleCalendarSync(cUserId).catch((err) => { + logger.error( + `Something went wrong with starting calendar sync for user ${cUserId}`, + err, + ); + }); } async function googleSignup( @@ -323,7 +321,7 @@ async function handleGoogleAuth(success: GoogleSignInSuccess): Promise { } } -const googleAuthService = { +export const googleAuthService = { googleSignup, repairGoogleConnection, getConnectedCompassUserId, @@ -331,5 +329,3 @@ const googleAuthService = { googleSignin, handleGoogleAuth, }; - -export default googleAuthService; diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts index 6d2786388..74dde14d7 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import { type Credentials, type TokenPayload } from "google-auth-library"; -import googleAuthService from "@backend/auth/services/google/google.auth.service"; +import { googleAuthService } from "@backend/auth/services/google/google.auth.service"; import { type AuthDecision, type GoogleSignInSuccess, diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index 1e7c4359f..e8e68c82c 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -24,7 +24,7 @@ import { } from "@backend/common/types/error.types"; import { type SessionResponse } from "@backend/common/types/express.types"; import { sseServer } from "@backend/servers/sse/sse.server"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { getSyncByToken } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; diff --git a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts index 4de2dde63..da1b3b14e 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts @@ -5,7 +5,7 @@ import { type RecipeInterface as ThirdPartyRecipeInterface } from "supertokens-n import { NodeEnv } from "@core/constants/core.constants"; import { Logger } from "@core/logger/winston.logger"; import { zObjectId } from "@core/types/type.utils"; -import googleAuthService from "@backend/auth/services/google/google.auth.service"; +import { googleAuthService } from "@backend/auth/services/google/google.auth.service"; import { type GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.types"; import { ENV } from "@backend/common/constants/env.constants"; import { diff --git a/packages/backend/src/common/middleware/supertokens.middleware.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.test.ts index 96f76c796..2d5118e1e 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.test.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.test.ts @@ -11,7 +11,7 @@ import { SELF_HOST_GOOGLE_CLIENT_ID_PLACEHOLDER, SELF_HOST_GOOGLE_CLIENT_SECRET_PLACEHOLDER, } from "@core/constants/core.constants"; -import googleAuthService from "@backend/auth/services/google/google.auth.service"; +import { googleAuthService } from "@backend/auth/services/google/google.auth.service"; import { ENV } from "@backend/common/constants/env.constants"; import { initSupertokens, @@ -83,7 +83,7 @@ jest.mock("supertokens-node/recipe/usermetadata", () => ({ jest.mock("@backend/auth/services/google/google.auth.service", () => ({ __esModule: true, - default: { + googleAuthService: { getConnectedCompassUserId: jest.fn(), handleGoogleAuth: jest.fn(), }, diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index f94b7754a..32cc03e0b 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -30,8 +30,8 @@ import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/e import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; import * as syncQueries from "@backend/sync/util/sync.queries"; import { updateSync } from "@backend/sync/util/sync.queries"; diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 6552a057f..7099f0953 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -20,9 +20,9 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; -import syncChannelMaintenanceService from "@backend/sync/services/channel/sync-channel-maintenance.service"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelMaintenanceService } from "@backend/sync/services/channel/sync-channel-maintenance.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { getSync } from "@backend/sync/util/sync.queries"; import { isMissingGoogleRefreshToken } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; diff --git a/packages/backend/src/sync/controllers/sync.debug.controller.ts b/packages/backend/src/sync/controllers/sync.debug.controller.ts index 8c3b767e3..f1b344b2a 100644 --- a/packages/backend/src/sync/controllers/sync.debug.controller.ts +++ b/packages/backend/src/sync/controllers/sync.debug.controller.ts @@ -7,9 +7,9 @@ import { type SReqBody, } from "@backend/common/types/express.types"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncChannelService from "../services/channel/sync-channel.service"; -import syncChannelMaintenanceService from "../services/channel/sync-channel-maintenance.service"; -import googleSyncLifecycleService from "../services/lifecycle/google-sync-lifecycle.service"; +import { syncChannelService } from "../services/channel/sync-channel.service"; +import { syncChannelMaintenanceService } from "../services/channel/sync-channel-maintenance.service"; +import { googleSyncLifecycleService } from "../services/lifecycle/google-sync-lifecycle.service"; import { getSync } from "../util/sync.queries"; class SyncDebugController { diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts index 01c7397a8..322b16477 100644 --- a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts +++ b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts @@ -7,7 +7,7 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; -import syncChannelMaintenanceService from "@backend/sync/services/channel/sync-channel-maintenance.service"; +import { syncChannelMaintenanceService } from "@backend/sync/services/channel/sync-channel-maintenance.service"; describe("syncChannelMaintenanceService", () => { beforeAll(initSupertokens); diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts index 438d877b1..c590a36a4 100644 --- a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts +++ b/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts @@ -138,5 +138,3 @@ export const syncChannelMaintenanceService = { runMaintenance, runMaintenanceByUser, }; - -export default syncChannelMaintenanceService; diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.test.ts b/packages/backend/src/sync/services/channel/sync-channel.service.test.ts index 7c17ab109..91bb93d71 100644 --- a/packages/backend/src/sync/services/channel/sync-channel.service.test.ts +++ b/packages/backend/src/sync/services/channel/sync-channel.service.test.ts @@ -13,7 +13,7 @@ import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; jest.mock("@backend/sync/util/sync.util", () => { diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.ts b/packages/backend/src/sync/services/channel/sync-channel.service.ts index a6f1b8a48..adf335ebd 100644 --- a/packages/backend/src/sync/services/channel/sync-channel.service.ts +++ b/packages/backend/src/sync/services/channel/sync-channel.service.ts @@ -268,7 +268,10 @@ async function startWatchingGcalCalendars( const resourceId = gcalWatch.resourceId; if (!resourceId) { - throw error(GcalError.Unsure, "Calendar watch response missing resourceId"); + throw error( + GcalError.Unsure, + "Calendar watch response missing resourceId", + ); } const watch = await mongoService.watch @@ -408,7 +411,9 @@ async function stopWatch( if (status === 404) { await mongoService.watch.deleteOne(filter, { session }); - logger.warn("Channel no longer exists. Corresponding sync record deleted"); + logger.warn( + "Channel no longer exists. Corresponding sync record deleted", + ); return undefined; } @@ -493,5 +498,3 @@ export const syncChannelService = { stopWatch, stopWatches, }; - -export default syncChannelService; diff --git a/packages/backend/src/sync/services/import/sync.import.ts b/packages/backend/src/sync/services/import/sync.import.ts index 6dac21147..14cfff750 100644 --- a/packages/backend/src/sync/services/import/sync.import.ts +++ b/packages/backend/src/sync/services/import/sync.import.ts @@ -26,7 +26,7 @@ import { SyncError } from "@backend/common/errors/sync/sync.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { getGcalWebhookBaseURL } from "@backend/common/util/api-base-url.util"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import { type EventsToModify } from "@backend/sync/services/import/sync.import.types"; import { organizeGcalEventsByType } from "@backend/sync/services/import/sync.import.util"; import { getCalendarsToSync } from "@backend/sync/services/init/sync.init"; diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts index 5187cab85..c36415585 100644 --- a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts +++ b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts @@ -7,9 +7,9 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import * as syncImportService from "@backend/sync/services/import/sync.import"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -65,11 +65,8 @@ describe("googleSyncLifecycleService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const callOrder: string[] = []; - const importFull = googleSyncLifecycleService.importFull.bind( - googleSyncLifecycleService, - ); - const startWatching = - syncChannelService.startWatchingGcalResources.bind(syncChannelService); + const importFull = googleSyncLifecycleService.importFull; + const startWatching = syncChannelService.startWatchingGcalResources; jest .spyOn(googleSyncLifecycleService, "importFull") diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts index 939be77b4..29b65704b 100644 --- a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts +++ b/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts @@ -11,7 +11,7 @@ import { getGoogleRepairErrorMessage } from "@backend/common/errors/integration/ import { isInvalidGoogleToken } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import { createSyncImport } from "@backend/sync/services/import/sync.import"; import compassGoogleMirrorService from "@backend/sync/services/outbound/compass-google-mirror.service"; import { updateSync } from "@backend/sync/util/sync.queries"; @@ -295,5 +295,3 @@ export const googleSyncLifecycleService = { restartGoogleCalendarSync, startGoogleCalendarSync, }; - -export default googleSyncLifecycleService; diff --git a/packages/backend/src/sync/services/maintain/sync.maintenance.ts b/packages/backend/src/sync/services/maintain/sync.maintenance.ts index 438f65318..b6d4aac70 100644 --- a/packages/backend/src/sync/services/maintain/sync.maintenance.ts +++ b/packages/backend/src/sync/services/maintain/sync.maintenance.ts @@ -9,8 +9,8 @@ import { isInvalidGoogleToken, } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { hasUpdatedCompassEventRecently } from "@backend/sync/util/sync.queries"; import { syncExpired, syncExpiresSoon } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index 6a8607f51..72823c84a 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -8,7 +8,7 @@ import { setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- mock factory spreads requireActual diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index 20d6a94f6..cea1b8785 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -19,8 +19,8 @@ import * as supertokensMiddleware from "@backend/common/middleware/supertokens.m import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; import priorityService from "@backend/priority/services/priority.service"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; -import googleSyncLifecycleService from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { type Summary_Delete } from "@backend/user/types/user.types"; diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index d893da245..bdd257a70 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -20,7 +20,7 @@ import { normalizeEmail } from "@backend/common/helpers/email.util"; import mongoService from "@backend/common/services/mongo.service"; import eventService from "@backend/event/services/event.service"; import priorityService from "@backend/priority/services/priority.service"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import syncRecords from "@backend/sync/services/records/sync.records"; import { findCanonicalCompassUser } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; diff --git a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts index 0add490ef..2acb585b6 100644 --- a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts +++ b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts @@ -9,7 +9,7 @@ import { getGcalClient } from "@backend/auth/services/google/clients/google.cale import { MONGO_BATCH_SIZE } from "@backend/common/constants/backend.constants"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import syncChannelService from "@backend/sync/services/channel/sync-channel.service"; +import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import { getChannelExpiration } from "@backend/sync/util/sync.util"; export default class Migration implements RunnableMigration { From 0e9a50cd6ee524aa4a6fe293d5d1b6d894004f1c Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 11:47:18 -0500 Subject: [PATCH 07/13] docs(auth): explain google sign-in override --- .../backend/src/common/middleware/supertokens.middleware.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index ce3c5daae..47014f31a 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -67,6 +67,8 @@ const googleThirdPartyOverride: NonNullable = { return { ...originalImplementation, async signInUpPOST(input) { + // Keep this at the API layer: Compass may need to replace the session + // SuperTokens creates before Google sync/auth work runs. const signInUpPOST = originalImplementation.signInUpPOST; if (!signInUpPOST) { From 7a688f2188f25e846347d10d1c37553e066cd5c7 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 13:04:59 -0500 Subject: [PATCH 08/13] chore(docs): remove google sync lifecycle report --- ...1-google-sync-lifecycle-refactor-report.md | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md diff --git a/docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md b/docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md deleted file mode 100644 index e7d53466b..000000000 --- a/docs/superpowers/reports/2026-05-01-google-sync-lifecycle-refactor-report.md +++ /dev/null @@ -1,31 +0,0 @@ -# Google Sync Lifecycle Refactor Report - -## What Changed - -Google sync is now organized around the actual product flows: Sync Channels, -Google sync lifecycle, Google event import/mirroring, and Draft Event -interaction. - -The old all-purpose sync service was removed. Callers now use the module that -owns the behaviour they need. - -## What Was Verified - -- `bun run test:core` -- `bun run test:backend` -- `bun run test:web` -- `bun run type-check` -- `bun run lint` - -## Behaviour Notes - -The refactor is intended to preserve existing user-visible behaviour. The main -improvement is that Google sync errors, repairs, watch notifications, and draft -editing decisions are easier to understand and test. - -## Remaining Risk - -Google Calendar behaviour still depends on external Google APIs and public -webhook delivery. Local tests cover Compass decisions and recovery paths, but -production webhook delivery should still be checked before relying on continuous -sync. From 937d2fbf03a9aa8ccdd36e16200182eb1b83207e Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 13:08:26 -0500 Subject: [PATCH 09/13] chore(web): move draft cleanup out of sync refactor --- .../hooks/actions/draft.movement.test.ts | 61 ---------- .../Draft/hooks/actions/draft.movement.ts | 58 ---------- .../actions/draft.submit-decision.test.ts | 59 ---------- .../hooks/actions/draft.submit-decision.ts | 25 ---- .../Draft/hooks/actions/submit.parser.test.ts | 34 +++--- .../Draft/hooks/actions/useDraftActions.ts | 108 +++++++++++------- 6 files changed, 84 insertions(+), 261 deletions(-) delete mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts delete mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts delete mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts delete mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts deleted file mode 100644 index a21522fef..000000000 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; -import dayjs from "@core/util/date/dayjs"; -import { - getDraggedEventDateRange, - getIsValidResizeMovement, -} from "./draft.movement"; -import { describe, expect, it } from "bun:test"; - -describe("draft movement helpers", () => { - it("keeps timed drag ranges within the same day when the end would overflow", () => { - const start = dayjs("2024-01-15T23:45:00.000"); - - const result = getDraggedEventDateRange({ - eventStart: start, - durationMin: 60, - isAllDay: false, - }); - - expect(dayjs(result.startDate).format("HH:mm")).toBe("23:00"); - expect(dayjs(result.endDate).format("HH:mm")).toBe("00:00"); - }); - - it("formats all-day drag ranges as date-only values", () => { - const start = dayjs("2024-01-15T09:00:00.000Z"); - - const result = getDraggedEventDateRange({ - eventStart: start, - durationMin: 1440, - isAllDay: true, - }); - - expect(result.startDate).toBe(start.format(YEAR_MONTH_DAY_FORMAT)); - expect(result.endDate).toBe( - start.add(1440, "minutes").format(YEAR_MONTH_DAY_FORMAT), - ); - }); - - it("rejects resize movement that changes a timed event to another day", () => { - expect( - getIsValidResizeMovement({ - currTime: dayjs("2024-01-16T10:00:00.000Z"), - draftStartDate: "2024-01-15T09:00:00.000Z", - currentValue: "2024-01-15T10:00:00.000Z", - dateBeingChanged: "endDate", - isAllDay: false, - }), - ).toBe(false); - }); - - it("accepts all-day resize movement across dates", () => { - expect( - getIsValidResizeMovement({ - currTime: dayjs("2024-01-16T00:00:00.000Z"), - draftStartDate: "2024-01-15", - currentValue: "2024-01-15", - dateBeingChanged: "endDate", - isAllDay: true, - }), - ).toBe(true); - }); -}); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts deleted file mode 100644 index 0391daca4..000000000 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; -import dayjs, { type Dayjs } from "@core/util/date/dayjs"; - -interface Params_GetDraggedEventDateRange { - eventStart: Dayjs; - durationMin: number; - isAllDay: boolean; -} - -export const getDraggedEventDateRange = ({ - eventStart, - durationMin, - isAllDay, -}: Params_GetDraggedEventDateRange) => { - let adjustedStart = eventStart; - let adjustedEnd = eventStart.add(durationMin, "minutes"); - - if (!isAllDay && adjustedEnd.date() !== adjustedStart.date()) { - adjustedEnd = adjustedEnd.hour(0).minute(0); - adjustedStart = adjustedEnd.subtract(durationMin, "minutes"); - } - - return { - startDate: isAllDay - ? adjustedStart.format(YEAR_MONTH_DAY_FORMAT) - : adjustedStart.format(), - endDate: isAllDay - ? adjustedEnd.format(YEAR_MONTH_DAY_FORMAT) - : adjustedEnd.format(), - }; -}; - -interface Params_GetIsValidResizeMovement { - currTime: Dayjs; - draftStartDate: string; - currentValue?: string; - dateBeingChanged: "startDate" | "endDate" | null; - isAllDay: boolean; -} - -export const getIsValidResizeMovement = ({ - currTime, - draftStartDate, - currentValue, - dateBeingChanged, - isAllDay, -}: Params_GetIsValidResizeMovement) => { - if (!dateBeingChanged) return false; - if (isAllDay) return true; - - const formatted = currTime.format(); - if (currentValue === formatted) return false; - - const isDifferentDay = currTime.day() !== dayjs(draftStartDate).day(); - if (isDifferentDay) return false; - - return formatted !== draftStartDate; -}; diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts deleted file mode 100644 index cd14dca2a..000000000 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { getDraftSubmitAction } from "./draft.submit-decision"; -import { describe, expect, it } from "bun:test"; - -describe("getDraftSubmitAction", () => { - it("creates a new event when the draft has no id", () => { - expect( - getDraftSubmitAction({ - draft: { title: "New" }, - pendingEventIds: [], - isFormOpenBeforeDragging: false, - isDirty: false, - }), - ).toBe("CREATE"); - }); - - it("discards a pending event update", () => { - expect( - getDraftSubmitAction({ - draft: { _id: "pending-id", title: "Pending" }, - pendingEventIds: ["pending-id"], - isFormOpenBeforeDragging: false, - isDirty: true, - }), - ).toBe("DISCARD"); - }); - - it("opens the form again after a drag that started from an open form", () => { - expect( - getDraftSubmitAction({ - draft: { _id: "event-id", title: "Existing" }, - pendingEventIds: [], - isFormOpenBeforeDragging: true, - isDirty: true, - }), - ).toBe("OPEN_FORM"); - }); - - it("discards unchanged existing events", () => { - expect( - getDraftSubmitAction({ - draft: { _id: "event-id", title: "Existing" }, - pendingEventIds: [], - isFormOpenBeforeDragging: false, - isDirty: false, - }), - ).toBe("DISCARD"); - }); - - it("updates changed existing events", () => { - expect( - getDraftSubmitAction({ - draft: { _id: "event-id", title: "Existing" }, - pendingEventIds: [], - isFormOpenBeforeDragging: false, - isDirty: true, - }), - ).toBe("UPDATE"); - }); -}); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts deleted file mode 100644 index 59520463b..000000000 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type DraftSubmitAction = "CREATE" | "DISCARD" | "OPEN_FORM" | "UPDATE"; - -type DraftIdentity = { - _id?: string | null; -}; - -interface Params_GetDraftSubmitAction { - draft: DraftIdentity; - pendingEventIds: string[]; - isFormOpenBeforeDragging: boolean | null; - isDirty: boolean; -} - -export const getDraftSubmitAction = ({ - draft, - pendingEventIds, - isFormOpenBeforeDragging, - isDirty, -}: Params_GetDraftSubmitAction): DraftSubmitAction => { - if (!draft._id) return "CREATE"; - if (pendingEventIds.includes(draft._id)) return "DISCARD"; - if (isFormOpenBeforeDragging) return "OPEN_FORM"; - if (!isDirty) return "DISCARD"; - return "UPDATE"; -}; diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts index 0c730c1e4..d563fdf99 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts @@ -29,11 +29,9 @@ mock.module("@web/common/validators/grid.event.validator", () => ({ mock.module("@web/common/validators/someday.event.validator", () => ({ validateSomedayEvent, - validateSomedayEvents: mock((events: Schema_SomedayEvent[]) => events), })); mock.module("@web/common/utils/event/event.util", () => ({ - assembleDefaultEvent: mock(async () => createMockGridEvent()), assembleGridEvent, })); @@ -325,26 +323,26 @@ describe("submit.parser", () => { expect(result._id).toBeUndefined(); }); - it("should handle someday event with missing startDate", () => { + it("should reject someday event with missing startDate", () => { const draft = createMockSomedayEvent({ startDate: undefined, }); const userId = "test-user-id"; - // The function uses non-null assertion, so it will pass undefined - const result = parseSomedayEventBeforeSubmit(draft, userId); - expect(result.startDate).toBeUndefined(); + expect(() => parseSomedayEventBeforeSubmit(draft, userId)).toThrow( + "Someday event requires startDate and endDate", + ); }); - it("should handle someday event with missing endDate", () => { + it("should reject someday event with missing endDate", () => { const draft = createMockSomedayEvent({ endDate: undefined, }); const userId = "test-user-id"; - // The function uses non-null assertion, so it will pass undefined - const result = parseSomedayEventBeforeSubmit(draft, userId); - expect(result.endDate).toBeUndefined(); + expect(() => parseSomedayEventBeforeSubmit(draft, userId)).toThrow( + "Someday event requires startDate and endDate", + ); }); it("should handle grid event with missing _id", () => { @@ -358,26 +356,26 @@ describe("submit.parser", () => { expect(result._id).toBeUndefined(); }); - it("should handle grid event with missing startDate", () => { + it("should reject grid event with missing startDate", () => { const draft = createMockGridEvent({ startDate: undefined, }); const userId = "test-user-id"; - // The function uses non-null assertion, so it will pass undefined - const result = prepEventBeforeSubmit(draft, userId); - expect(result.startDate).toBeUndefined(); + expect(() => prepEventBeforeSubmit(draft, userId)).toThrow( + "Event requires startDate and endDate", + ); }); - it("should handle grid event with missing endDate", () => { + it("should reject grid event with missing endDate", () => { const draft = createMockGridEvent({ endDate: undefined, }); const userId = "test-user-id"; - // The function uses non-null assertion, so it will pass undefined - const result = prepEventBeforeSubmit(draft, userId); - expect(result.endDate).toBeUndefined(); + expect(() => prepEventBeforeSubmit(draft, userId)).toThrow( + "Event requires startDate and endDate", + ); }); it("should handle grid event with missing user", () => { diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts index e8786a95c..181434ceb 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts @@ -1,5 +1,5 @@ import { ObjectId } from "bson"; -import { type MouseEvent, useCallback } from "react"; +import { useCallback } from "react"; import { Priorities, SOMEDAY_WEEK_LIMIT_MSG, @@ -11,7 +11,6 @@ import { type Recurrence, RecurringEventUpdateScope, type Schema_Event, - type WithCompassId, } from "@core/types/event.types"; import { StringV4Schema } from "@core/types/type.utils"; import { devAlert } from "@core/util/app.util"; @@ -19,12 +18,15 @@ import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { DirtyParser } from "@web/common/parsers/dirty.parser"; import { EventInViewParser } from "@web/common/parsers/view.parser"; import { type PartialMouseEvent } from "@web/common/types/util.types"; +import { assembleDefaultEvent } from "@web/common/utils/event/event.util"; +import { + type Payload_ConvertEvent, + type Payload_EditEvent, +} from "@web/ducks/events/event.types"; import { type Schema_GridEvent, type Schema_WebEvent, } from "@web/common/types/web.event.types"; -import { assembleDefaultEvent } from "@web/common/utils/event/event.util"; -import { type Payload_EditEvent } from "@web/ducks/events/event.types"; import { selectDraft, selectDraftStatus, @@ -52,11 +54,6 @@ import { import { type DateCalcs } from "@web/views/Calendar/hooks/grid/useDateCalcs"; import { type WeekProps } from "@web/views/Calendar/hooks/useWeek"; import { GRID_TIME_STEP } from "@web/views/Calendar/layout.constants"; -import { - getDraggedEventDateRange, - getIsValidResizeMovement, -} from "./draft.movement"; -import { getDraftSubmitAction } from "./draft.submit-decision"; import { getDragDurationMinutes } from "./drag-duration.util"; export const useDraftActions = ( @@ -202,10 +199,10 @@ export const useDraftActions = ( return; } - const event: WithCompassId> = { + const event: Payload_ConvertEvent["event"] = { ...draft, - _id: draft!._id, - user: draft?.user, + _id: draft!._id!, + user: draft?.user ?? "", isAllDay: false, isSomeday: true, startDate: start, @@ -241,16 +238,31 @@ export const useDraftActions = ( const determineSubmitAction = useCallback( (draft: Schema_WebEvent) => { - const isDirty = reduxDraft - ? DirtyParser.isEventDirty(draft, reduxDraft) - : true; - - return getDraftSubmitAction({ - draft, - pendingEventIds, - isFormOpenBeforeDragging, - isDirty, - }); + const isExisting = !!draft._id; + if (!isExisting) return "CREATE"; + + if (isExisting) { + // Prevent updates if event is pending (waiting for backend confirmation) + const isPending = draft._id + ? pendingEventIds.includes(draft._id) + : false; + if (isPending) { + // Event is pending, discard the change and return to original position + return "DISCARD"; + } + + if (isFormOpenBeforeDragging) { + return "OPEN_FORM"; + } + const isSame = reduxDraft + ? !DirtyParser.isEventDirty(draft, reduxDraft) + : false; + if (isSame) { + // no need to make HTTP request + return "DISCARD"; + } + } + return "UPDATE"; }, [reduxDraft, isFormOpenBeforeDragging, pendingEventIds], ); @@ -327,9 +339,7 @@ export const useDraftActions = ( const event = new OnSubmitParser(draft).parse(); const payload = getEditSlicePayload(event, applyTo); - dispatch( - editEventSlice.actions.request(payload as unknown as undefined), - ); + dispatch(editEventSlice.actions.request(payload)); if (shouldAddToView(event)) { dispatch(getWeekEventsSlice.actions.insert(event._id!)); @@ -380,22 +390,31 @@ export const useDraftActions = ( const y = e.clientY - draft.position.dragOffset.y; - const eventStart = dateCalcs.getDateByXY( + let eventStart = dateCalcs.getDateByXY( x, y, weekProps.component.startOfView, ); - const { startDate, endDate } = getDraggedEventDateRange({ - eventStart, - durationMin: startEndDurationMin, - isAllDay: draft.isAllDay, - }); + let eventEnd = eventStart.add(startEndDurationMin, "minutes"); + + if (!draft.isAllDay) { + // Edge case: timed events' end times can overflow past midnight at the bottom of the grid. + // Below logic prevents that from occurring. + if (eventEnd.date() !== eventStart.date()) { + eventEnd = eventEnd.hour(0).minute(0); + eventStart = eventEnd.subtract(startEndDurationMin, "minutes"); + } + } const _draft: Schema_GridEvent = { ...draft, - startDate, - endDate, + startDate: draft.isAllDay + ? eventStart.format(YEAR_MONTH_DAY_FORMAT) + : eventStart.format(), + endDate: draft.isAllDay + ? eventEnd.format(YEAR_MONTH_DAY_FORMAT) + : eventEnd.format(), priority: draft.priority || Priorities.UNASSIGNED, }; @@ -439,13 +458,22 @@ export const useDraftActions = ( (currTime: dayjs.Dayjs) => { if (!draft || !dateBeingChanged) return false; - return getIsValidResizeMovement({ - currTime, - draftStartDate: draft.startDate, - currentValue: draft[dateBeingChanged], - dateBeingChanged, - isAllDay: draft.isAllDay, - }); + if (draft.isAllDay) { + return true; + } + + const _currTime = currTime.format(); + const noChange = draft[dateBeingChanged] === _currTime; + + if (noChange) return false; + + const diffDay = currTime.day() !== dayjs(draft.startDate).day(); + if (diffDay) return false; + + const sameStart = currTime.format() === draft.startDate; + if (sameStart) return false; + + return true; }, [dateBeingChanged, draft], ); From e7df09a77117323da1e9a22f53b13114ba145c8f Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 13:10:09 -0500 Subject: [PATCH 10/13] chore(web): remove remaining draft action changes --- .../Draft/hooks/actions/submit.parser.test.ts | 32 +++++++++---------- .../Draft/hooks/actions/useDraftActions.ts | 20 ++++++------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts index d563fdf99..5959e0738 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts @@ -323,26 +323,26 @@ describe("submit.parser", () => { expect(result._id).toBeUndefined(); }); - it("should reject someday event with missing startDate", () => { + it("should handle someday event with missing startDate", () => { const draft = createMockSomedayEvent({ startDate: undefined, }); const userId = "test-user-id"; - expect(() => parseSomedayEventBeforeSubmit(draft, userId)).toThrow( - "Someday event requires startDate and endDate", - ); + // The function uses non-null assertion, so it will pass undefined + const result = parseSomedayEventBeforeSubmit(draft, userId); + expect(result.startDate).toBeUndefined(); }); - it("should reject someday event with missing endDate", () => { + it("should handle someday event with missing endDate", () => { const draft = createMockSomedayEvent({ endDate: undefined, }); const userId = "test-user-id"; - expect(() => parseSomedayEventBeforeSubmit(draft, userId)).toThrow( - "Someday event requires startDate and endDate", - ); + // The function uses non-null assertion, so it will pass undefined + const result = parseSomedayEventBeforeSubmit(draft, userId); + expect(result.endDate).toBeUndefined(); }); it("should handle grid event with missing _id", () => { @@ -356,26 +356,26 @@ describe("submit.parser", () => { expect(result._id).toBeUndefined(); }); - it("should reject grid event with missing startDate", () => { + it("should handle grid event with missing startDate", () => { const draft = createMockGridEvent({ startDate: undefined, }); const userId = "test-user-id"; - expect(() => prepEventBeforeSubmit(draft, userId)).toThrow( - "Event requires startDate and endDate", - ); + // The function uses non-null assertion, so it will pass undefined + const result = prepEventBeforeSubmit(draft, userId); + expect(result.startDate).toBeUndefined(); }); - it("should reject grid event with missing endDate", () => { + it("should handle grid event with missing endDate", () => { const draft = createMockGridEvent({ endDate: undefined, }); const userId = "test-user-id"; - expect(() => prepEventBeforeSubmit(draft, userId)).toThrow( - "Event requires startDate and endDate", - ); + // The function uses non-null assertion, so it will pass undefined + const result = prepEventBeforeSubmit(draft, userId); + expect(result.endDate).toBeUndefined(); }); it("should handle grid event with missing user", () => { diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts index 181434ceb..d238fd651 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts @@ -1,5 +1,5 @@ import { ObjectId } from "bson"; -import { useCallback } from "react"; +import { type MouseEvent, useCallback } from "react"; import { Priorities, SOMEDAY_WEEK_LIMIT_MSG, @@ -11,6 +11,7 @@ import { type Recurrence, RecurringEventUpdateScope, type Schema_Event, + type WithCompassId, } from "@core/types/event.types"; import { StringV4Schema } from "@core/types/type.utils"; import { devAlert } from "@core/util/app.util"; @@ -18,15 +19,12 @@ import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { DirtyParser } from "@web/common/parsers/dirty.parser"; import { EventInViewParser } from "@web/common/parsers/view.parser"; import { type PartialMouseEvent } from "@web/common/types/util.types"; -import { assembleDefaultEvent } from "@web/common/utils/event/event.util"; -import { - type Payload_ConvertEvent, - type Payload_EditEvent, -} from "@web/ducks/events/event.types"; import { type Schema_GridEvent, type Schema_WebEvent, } from "@web/common/types/web.event.types"; +import { assembleDefaultEvent } from "@web/common/utils/event/event.util"; +import { type Payload_EditEvent } from "@web/ducks/events/event.types"; import { selectDraft, selectDraftStatus, @@ -199,10 +197,10 @@ export const useDraftActions = ( return; } - const event: Payload_ConvertEvent["event"] = { + const event: WithCompassId> = { ...draft, - _id: draft!._id!, - user: draft?.user ?? "", + _id: draft!._id, + user: draft?.user, isAllDay: false, isSomeday: true, startDate: start, @@ -339,7 +337,9 @@ export const useDraftActions = ( const event = new OnSubmitParser(draft).parse(); const payload = getEditSlicePayload(event, applyTo); - dispatch(editEventSlice.actions.request(payload)); + dispatch( + editEventSlice.actions.request(payload as unknown as undefined), + ); if (shouldAddToView(event)) { dispatch(getWeekEventsSlice.actions.insert(event._id!)); From ab44fad9fb61fe42a91298c5d2eab3ac8b4880dc Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 15:16:05 -0500 Subject: [PATCH 11/13] fix(backend): add safe google auth trace --- .../google/google.auth.service.test.ts | 89 +++++++++++++++++++ .../services/google/google.auth.service.ts | 68 +++++++++++++- packages/backend/tsconfig.json | 4 + 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index 97abd092f..3b402ad0d 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -21,6 +21,31 @@ import { type GoogleSignInSuccess, } from "./google.auth.types"; +type MockLoggerModule = { + __mockLogger: { + debug: jest.Mock; + error: jest.Mock; + info: jest.Mock; + verbose: jest.Mock; + warn: jest.Mock; + }; +}; + +jest.mock("@core/logger/winston.logger", () => { + const mockLogger: MockLoggerModule["__mockLogger"] = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + }; + + return { + __mockLogger: mockLogger, + Logger: jest.fn(() => mockLogger), + }; +}); + jest.mock("@backend/auth/services/google/util/google.auth.util", () => { const actual = jest.requireActual( "@backend/auth/services/google/util/google.auth.util", @@ -61,6 +86,9 @@ describe("googleAuthService", () => { ({ access_token: faker.internet.jwt(), }) as Pick; + const getMockLogger = () => + (jest.requireMock("@core/logger/winston.logger") as MockLoggerModule) + .__mockLogger; beforeEach(() => { mockDetermineGoogleAuthMode.mockReset(); @@ -204,6 +232,67 @@ describe("googleAuthService", () => { oAuthTokens, ); }); + + it("logs a production-safe auth decision trace without raw identifiers", async () => { + const providerUser = makeProviderUser({ + email: "Trace.Person@example.com", + sub: "google-user-123", + }); + const recipeUserId = faker.database.mongodbObjectId(); + const compassUserId = faker.database.mongodbObjectId(); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, + recipeUserId, + loginMethodsLength: 2, + }; + + const decision: AuthDecision = { + authMode: "SIGNIN_INCREMENTAL", + compassUserId, + hasStoredRefreshToken: true, + hasHealthySync: true, + createdNewRecipeUser: false, + }; + + mockDetermineGoogleAuthMode.mockResolvedValue(decision); + + await googleAuthService.handleGoogleAuth(success); + + const mockLogger = getMockLogger(); + + expect(mockLogger.info).toHaveBeenCalledWith( + "google_auth_decision", + expect.objectContaining({ + authMode: "SIGNIN_INCREMENTAL", + compassUserTraceId: expect.any(String), + createdNewRecipeUser: false, + googleUserTraceId: expect.any(String), + hasCompassUserId: true, + hasGoogleUserId: true, + hasHealthySync: true, + hasProviderEmail: true, + hasStoredRefreshToken: true, + loginMethodsLength: 2, + providerEmailTraceId: expect.any(String), + }), + ); + + const tracePayload = mockLogger.info.mock.calls.find( + ([message]) => message === "google_auth_decision", + )?.[1]; + const serializedTrace = JSON.stringify(tracePayload); + + expect(tracePayload).not.toHaveProperty("compassUserId"); + expect(tracePayload).not.toHaveProperty("googleUserId"); + expect(tracePayload).not.toHaveProperty("providerEmail"); + expect(serializedTrace).not.toContain(compassUserId); + expect(serializedTrace).not.toContain(providerUser.email); + expect(serializedTrace).not.toContain(providerUser.sub); + }); }); describe("repairGoogleConnection", () => { diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index 4e3fdbded..31789afee 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -7,6 +7,7 @@ import { determineGoogleAuthMode, parseReconnectGoogleParams, } from "@backend/auth/services/google/util/google.auth.util"; +import { ENV } from "@backend/common/constants/env.constants"; import { AuthError } from "@backend/common/errors/auth/auth.errors"; import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; @@ -18,9 +19,63 @@ import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/goo import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; -import { type GoogleSignInSuccess } from "./google.auth.types"; +import { + type AuthDecision, + type GoogleSignInSuccess, +} from "./google.auth.types"; +import { createHmac } from "node:crypto"; const logger = Logger("app:auth.google.service"); +const AUTH_TRACE_ID_LENGTH = 16; + +// Keep auth traces searchable without putting raw user identifiers in production logs. +function getTraceId(value: string | null | undefined): string | undefined { + const normalizedValue = value?.trim(); + + if (!normalizedValue) { + return undefined; + } + + return createHmac("sha256", ENV.TOKEN_COMPASS_SYNC) + .update(normalizedValue) + .digest("hex") + .slice(0, AUTH_TRACE_ID_LENGTH); +} + +function getGoogleAuthDecisionTrace({ + createdNewRecipeUser, + decision, + googleUserId, + loginMethodsLength, + providerEmail, +}: { + createdNewRecipeUser: boolean; + decision: AuthDecision; + googleUserId: string; + loginMethodsLength: number; + providerEmail: string | null | undefined; +}) { + const providerEmailTraceId = providerEmail + ? getTraceId(normalizeEmail(providerEmail)) + : undefined; + const googleUserTraceId = getTraceId(googleUserId); + const compassUserTraceId = getTraceId(decision.compassUserId); + + return { + event: "google_auth_decision", + authMode: decision.authMode, + createdNewRecipeUser, + hasCompassUserId: Boolean(decision.compassUserId), + hasGoogleUserId: Boolean(googleUserId), + hasHealthySync: decision.hasHealthySync, + hasProviderEmail: Boolean(providerEmail), + hasStoredRefreshToken: decision.hasStoredRefreshToken, + loginMethodsLength, + ...(compassUserTraceId ? { compassUserTraceId } : {}), + ...(googleUserTraceId ? { googleUserTraceId } : {}), + ...(providerEmailTraceId ? { providerEmailTraceId } : {}), + }; +} async function persistGoogleConnection( compassUserId: string, @@ -271,6 +326,17 @@ async function handleGoogleAuth(success: GoogleSignInSuccess): Promise { createdNewRecipeUser, ); + logger.info( + "google_auth_decision", + getGoogleAuthDecisionTrace({ + createdNewRecipeUser, + decision, + googleUserId, + loginMethodsLength, + providerEmail: providerUser.email, + }), + ); + switch (decision.authMode) { case "SIGNUP": { const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index c3ff27c6b..68dfdb752 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/__tests__/**"], "compilerOptions": { + "composite": false, + "declarationMap": false, "sourceMap": false, "inlineSourceMap": true } From 31470aa25462ec92135d2248ef5a5fff2c878161 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 15:25:28 -0500 Subject: [PATCH 12/13] docs(auth): clarify google auth repair logs --- docs/development/troubleshoot.md | 8 ++++++++ docs/features/password-auth-flow.md | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/docs/development/troubleshoot.md b/docs/development/troubleshoot.md index e5ed8e92e..a82045171 100644 --- a/docs/development/troubleshoot.md +++ b/docs/development/troubleshoot.md @@ -90,6 +90,14 @@ To fix this: When a user triggers repair from the UI (`Repair Google Calendar`) and the flow does not complete as expected, first classify the failure mode from the message and SSE behavior. +For Google sign-in or reconnect repair, check the backend +`google_auth_decision` log. It shows the selected mode, token/sync health +flags, and safe trace ids. To keep sensitive account details out of logs, it +should not show raw emails, Google ids, or Compass user ids. + +If the mode is `RECONNECT_REPAIR` and a stored refresh token exists, backend can +repair with that token even when Google does not return a new one. + ### Quota / rate-limit during repair If the user sees: diff --git a/docs/features/password-auth-flow.md b/docs/features/password-auth-flow.md index ac390ee0c..eb40b67d8 100644 --- a/docs/features/password-auth-flow.md +++ b/docs/features/password-auth-flow.md @@ -251,6 +251,13 @@ The auth-mode decision is server-side and depends on: - whether a refresh token is stored - whether sync health is good enough for incremental sync +Backend logs this decision as `google_auth_decision`. To keep sensitive account +details out of logs, it uses the chosen mode and safe trace ids instead of raw +emails, Google ids, or Compass user ids. + +If repair has a stored refresh token, it can reuse that token even when Google +does not return a new one. + ### Google connect from an authenticated password session When a logged-in password user chooses `Connect Google Calendar`: From b6b151abf0247d5dd12562272a1c9fa069c3f447 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 17:15:04 -0500 Subject: [PATCH 13/13] refactor(backend): clarify google sync services --- CONTEXT.md | 8 ++- docs/architecture/glossary.md | 5 +- docs/backend/README.md | 6 +- docs/development/feature-file-map.md | 4 +- docs/features/google-sync-and-sse-flow.md | 18 +++--- .../src/__tests__/drivers/event.driver.ts | 2 +- .../src/__tests__/drivers/sync.driver.ts | 6 +- .../google/google.auth.service.test.ts | 20 +++---- .../services/google/google.auth.service.ts | 33 ++++++----- .../services/calendar.service.test.ts | 2 +- .../errors/handlers/error.express.handler.ts | 16 +++-- .../src/event/services/event.service.ts | 2 +- .../sync/controllers/sync.controller.test.ts | 30 +++++----- .../src/sync/controllers/sync.controller.ts | 56 +++++++++--------- .../sync/controllers/sync.debug.controller.ts | 26 +++++---- .../google-calendar-sync.service.test.ts} | 58 ++++++++++--------- .../google-calendar-sync.service.ts} | 40 +++++++------ .../google.calendar.client.test.ts | 2 +- .../google.calendar.client.ts | 2 +- .../src/sync/services/import/sync.import.ts | 6 +- .../src/sync/services/init/sync.init.test.ts | 2 +- .../google-watch-maintenance.planner.ts} | 16 +++-- .../google-watch-maintenance.service.test.ts} | 6 +- .../google-watch-maintenance.service.ts} | 8 +-- .../google-watch.service.test.ts} | 24 ++++---- .../google-watch.service.ts} | 52 ++++++++--------- .../backend/src/sync/sync.routes.config.ts | 2 +- .../services/user-metadata.service.test.ts | 6 +- .../src/user/services/user.service.test.ts | 18 +++--- .../backend/src/user/services/user.service.ts | 10 ++-- ....10.13T14.22.21.migrate-sync-watch-data.ts | 8 +-- 31 files changed, 252 insertions(+), 242 deletions(-) rename packages/backend/src/sync/services/{lifecycle/google-sync-lifecycle.service.test.ts => google-calendar-sync/google-calendar-sync.service.test.ts} (69%) rename packages/backend/src/sync/services/{lifecycle/google-sync-lifecycle.service.ts => google-calendar-sync/google-calendar-sync.service.ts} (88%) rename packages/backend/src/{auth/services/google/clients => sync/services/google-calendar-sync}/google.calendar.client.test.ts (95%) rename packages/backend/src/{auth/services/google/clients => sync/services/google-calendar-sync}/google.calendar.client.ts (97%) rename packages/backend/src/sync/services/{maintain/sync.maintenance.ts => watch/google-watch-maintenance.planner.ts} (88%) rename packages/backend/src/sync/services/{channel/sync-channel-maintenance.service.test.ts => watch/google-watch-maintenance.service.test.ts} (83%) rename packages/backend/src/sync/services/{channel/sync-channel-maintenance.service.ts => watch/google-watch-maintenance.service.ts} (94%) rename packages/backend/src/sync/services/{channel/sync-channel.service.test.ts => watch/google-watch.service.test.ts} (89%) rename packages/backend/src/sync/services/{channel/sync-channel.service.ts => watch/google-watch.service.ts} (90%) diff --git a/CONTEXT.md b/CONTEXT.md index 6976e1fac..2265edd79 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,9 +106,11 @@ in on one task. **Primary Calendar**: The main Google Calendar Compass currently syncs. -**Sync Channel**: -A Google Calendar watch channel used to notify Compass when Google-side -calendar data changes. +**Google Watch**: +A Google Calendar watch subscription used to notify Compass when Google-side +calendar data changes. Use "channel" only when referring to Google API fields +such as `channelId`. +_Avoid_: Sync Channel **nextSyncToken**: Google's cursor for incremental calendar sync. diff --git a/docs/architecture/glossary.md b/docs/architecture/glossary.md index 00ecffc21..81a944bcd 100644 --- a/docs/architecture/glossary.md +++ b/docs/architecture/glossary.md @@ -29,8 +29,9 @@ Following Events, or All Events. **Primary Calendar**: The main Google Calendar Compass currently syncs. Compass does not yet support choosing multiple Google calendars in the UI. -**Sync Channel**: A Google Calendar watch channel used to notify Compass when -Google-side calendar data changes. +**Google Watch**: A Google Calendar watch subscription used to notify Compass +when Google-side calendar data changes. Use "channel" only for Google API +fields such as `channelId`. **nextSyncToken**: Google's cursor for incremental calendar sync. diff --git a/docs/backend/README.md b/docs/backend/README.md index e63f645c8..9f236db98 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -65,9 +65,9 @@ Key files: - endpoint: `POST /api/sync/gcal/notifications` - source: `packages/backend/src/sync/controllers/sync.controller.ts` - middleware: `authMiddleware.verifyIsFromGoogle` -- notification owner: `packages/backend/src/sync/services/channel/sync-channel.service.ts` -- import/repair owner: `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` -- maintenance owner: `packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts` +- notification owner: `packages/backend/src/sync/services/watch/google-watch.service.ts` +- import/repair owner: `packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts` +- maintenance owner: `packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts` Observed outcomes include: diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index 5ab3c6b0d..707032f9e 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -82,8 +82,8 @@ Use this document to find the first files to inspect for common Compass changes. - Backend SSE server: `packages/backend/src/servers/sse/sse.server.ts` - Events stream route: `packages/backend/src/events/events.routes.config.ts` - Backend sync routes: `packages/backend/src/sync/sync.routes.config.ts` -- Sync Channel lifecycle and notifications: `packages/backend/src/sync/services/channel` -- Google import and repair lifecycle: `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- Google Watch lifecycle and notifications: `packages/backend/src/sync/services/watch` +- Google Calendar sync import and repair: `packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts` - Compass-to-Google repair mirroring: `packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts` - Sync record persistence: `packages/backend/src/sync/services/records/sync.records.ts` - Google import internals: `packages/backend/src/sync/services/import` diff --git a/docs/features/google-sync-and-sse-flow.md b/docs/features/google-sync-and-sse-flow.md index c40581899..18dbfb1e8 100644 --- a/docs/features/google-sync-and-sse-flow.md +++ b/docs/features/google-sync-and-sse-flow.md @@ -78,7 +78,7 @@ Source: - `packages/core/src/types/sse.types.ts` - `packages/backend/src/user/services/user.service.ts` -- `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- `packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts` `IMPORT_GCAL_END` carries an explicit `operation` so the client can distinguish repair completion from incremental completion. @@ -101,8 +101,8 @@ type ImportGCalEndPayload = Operational constraints: -- repair path (`restartGoogleCalendarSync`) emits `operation: "REPAIR"` -- incremental path (`importIncremental`) emits `operation: "INCREMENTAL"` +- repair path (`repairGoogleCalendarSync`) emits `operation: "REPAIR"` +- incremental path (`importLatestGoogleCalendarChanges`) emits `operation: "INCREMENTAL"` - web listeners should keep a defensive `payload?` handler for compatibility with older emitters/tests ## Outbound Flow: User Changes An Event In Compass @@ -130,7 +130,7 @@ High-level path: 1. Google posts to the notification endpoint in sync routes. 2. Backend verifies the request origin. -3. `syncChannelService.handleGcalNotification()` locates the watch and sync record. +3. `googleWatchService.handleGoogleWatchNotification()` locates the watch and sync record. 4. The service builds a Google Calendar client for the user. 5. `GCalNotificationHandler` fetches incremental changes using the stored sync token. 6. `GcalSyncProcessor` applies those changes to Compass data. @@ -139,21 +139,21 @@ High-level path: Primary files: - `packages/backend/src/sync/sync.routes.config.ts` -- `packages/backend/src/sync/services/channel/sync-channel.service.ts` -- `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- `packages/backend/src/sync/services/watch/google-watch.service.ts` +- `packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts` - `packages/backend/src/sync/services/records/sync.records.ts` - `packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts` - `packages/backend/src/sync/services/sync/google/gcal.sync.processor.ts` Lifecycle and outbound repair paths live in: -- `packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts` +- `packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts` - `packages/backend/src/sync/services/outbound/compass-google-mirror.service.ts` -- `packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts` +- `packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts` ### Notification Outcomes And Error Semantics -Same as before: recoverable `INITIALIZED` / `IGNORED` / `PROCESSED` paths, `GOOGLE_REVOKED` on invalid refresh token, etc. See inline comments in `syncChannelService`, `googleSyncLifecycleService`, and `SyncController`. +Same as before: recoverable `INITIALIZED` / `IGNORED` / `PROCESSED` paths, `GOOGLE_REVOKED` on invalid refresh token, etc. See inline comments in `googleWatchService`, `googleCalendarSyncService`, and `SyncController`. ## SSE Server Responsibilities diff --git a/packages/backend/src/__tests__/drivers/event.driver.ts b/packages/backend/src/__tests__/drivers/event.driver.ts index fd27acbe2..e583a16bd 100644 --- a/packages/backend/src/__tests__/drivers/event.driver.ts +++ b/packages/backend/src/__tests__/drivers/event.driver.ts @@ -4,9 +4,9 @@ import { type z } from "zod/v4"; import { Origin, Priorities } from "@core/constants/core.constants"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { mockGcalEvents } from "@backend/__tests__/mocks.gcal/factories/gcal.event.factory"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import calendarService from "@backend/calendar/services/calendar.service"; import mongoService from "@backend/common/services/mongo.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; export class EventDriver { static async generateV0Data(count = 3) { diff --git a/packages/backend/src/__tests__/drivers/sync.driver.ts b/packages/backend/src/__tests__/drivers/sync.driver.ts index 85da28b99..4721cc869 100644 --- a/packages/backend/src/__tests__/drivers/sync.driver.ts +++ b/packages/backend/src/__tests__/drivers/sync.driver.ts @@ -4,9 +4,9 @@ import { Resource_Sync, type Schema_Sync } from "@core/types/sync.types"; import { type Schema_User } from "@core/types/user.types"; import dayjs from "@core/util/date/dayjs"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import mongoService from "@backend/common/services/mongo.service"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { updateSync } from "@backend/sync/util/sync.queries"; export class SyncDriver { @@ -24,7 +24,7 @@ export class SyncDriver { await updateSync(Resource_Sync.EVENTS, user._id.toString(), gCalendarId, { nextSyncToken: faker.string.ulid(), }); - await syncChannelService.startWatchingGcalResources( + await googleWatchService.startGoogleWatches( user._id.toString(), [{ gCalendarId }, { gCalendarId: Resource_Sync.CALENDAR }], // Watch all selected calendars and calendar list gcal, diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index 3b402ad0d..a1efac13a 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -12,7 +12,7 @@ import { determineGoogleAuthMode } from "@backend/auth/services/google/util/goog import { AuthError } from "@backend/common/errors/auth/auth.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { googleAuthService } from "./google.auth.service"; @@ -314,7 +314,7 @@ describe("googleAuthService", () => { refresh_token: faker.string.uuid(), }; const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); await userService.pruneGoogleData(compassUserId); @@ -357,7 +357,7 @@ describe("googleAuthService", () => { }; const restartError = new Error("sync failed"); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockRejectedValue(restartError); await userService.pruneGoogleData(compassUserId); @@ -387,7 +387,7 @@ describe("googleAuthService", () => { picture: faker.image.url(), }); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); mockDetermineGoogleAuthMode.mockResolvedValue({ @@ -434,7 +434,7 @@ describe("googleAuthService", () => { picture: faker.image.url(), }); const importSpy = jest - .spyOn(googleSyncLifecycleService, "importIncremental") + .spyOn(googleCalendarSyncService, "importLatestGoogleCalendarChanges") .mockResolvedValue(undefined); await expect( @@ -469,7 +469,7 @@ describe("googleAuthService", () => { }); const refreshToken = faker.string.uuid(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -514,7 +514,7 @@ describe("googleAuthService", () => { withGoogle: false, }); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -553,7 +553,7 @@ describe("googleAuthService", () => { it("rejects when the Google account email does not match the current Compass user", async () => { const user = await UserDriver.createUser({ withGoogle: false }); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -603,7 +603,7 @@ describe("googleAuthService", () => { .spyOn(EmailService, "tagNewUserIfEnabled") .mockResolvedValue(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const result = await googleAuthService.googleSignup( @@ -636,7 +636,7 @@ describe("googleAuthService", () => { } as TokenPayload; const refreshToken = faker.string.uuid(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const tagNewUserSpy = jest .spyOn(EmailService, "tagNewUserIfEnabled") diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index 31789afee..160024b49 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -15,7 +15,7 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { normalizeEmail } from "@backend/common/helpers/email.util"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -95,7 +95,7 @@ async function persistGoogleConnection( }, }); - restartGoogleCalendarSyncInBackground(compassUserId); + startGoogleCalendarSyncIfNeededInBackground(compassUserId); return { cUserId: compassUserId }; } @@ -127,18 +127,20 @@ async function persistStoredGoogleConnection( }, }); - restartGoogleCalendarSyncInBackground(cUserId); + startGoogleCalendarSyncIfNeededInBackground(cUserId); return { cUserId }; } -function restartGoogleCalendarSyncInBackground(cUserId: string) { - googleSyncLifecycleService.restartGoogleCalendarSync(cUserId).catch((err) => { - logger.error( - `Something went wrong with starting calendar sync for user ${cUserId}`, - err, - ); - }); +function startGoogleCalendarSyncIfNeededInBackground(cUserId: string) { + googleCalendarSyncService + .startGoogleCalendarSyncIfNeeded(cUserId) + .catch((err) => { + logger.error( + `Something went wrong with starting calendar sync for user ${cUserId}`, + err, + ); + }); } async function googleSignup( @@ -177,7 +179,7 @@ async function googleSignup( return { cUserId: cUser.user.userId }; }); - restartGoogleCalendarSyncInBackground(user.cUserId); + startGoogleCalendarSyncIfNeededInBackground(user.cUserId); return user; } @@ -279,8 +281,11 @@ async function googleSignin( const googleOAuthClient = new GoogleOAuthClient(); googleOAuthClient.oauthClient.setCredentials(oAuthTokens); - googleSyncLifecycleService - .importIncremental(cUserId, googleOAuthClient.getGcalClient()) + googleCalendarSyncService + .importLatestGoogleCalendarChanges( + cUserId, + googleOAuthClient.getGcalClient(), + ) .catch(async (err) => { if ( err instanceof Error && @@ -295,7 +300,7 @@ async function googleSignin( data: { sync: { importGCal: "RESTART" } }, }); - restartGoogleCalendarSyncInBackground(cUserId); + startGoogleCalendarSyncIfNeededInBackground(cUserId); return; } diff --git a/packages/backend/src/calendar/services/calendar.service.test.ts b/packages/backend/src/calendar/services/calendar.service.test.ts index 7c3dac748..106e446b0 100644 --- a/packages/backend/src/calendar/services/calendar.service.test.ts +++ b/packages/backend/src/calendar/services/calendar.service.test.ts @@ -13,8 +13,8 @@ import { cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import calendarService from "@backend/calendar/services/calendar.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; describe("CalendarService", () => { beforeEach(setupTestDb); diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index e8e68c82c..f60ed672e 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -24,7 +24,7 @@ import { } from "@backend/common/types/error.types"; import { type SessionResponse } from "@backend/common/types/express.types"; import { sseServer } from "@backend/servers/sse/sse.server"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import { getSyncByToken } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; @@ -131,14 +131,12 @@ const handleGoogleError = async ( } if (isFullSyncRequired(e)) { - googleSyncLifecycleService - .restartGoogleCalendarSync(userId, { force: true }) - .catch((err) => { - logger.error( - `Something went wrong with resyncing google calendars for user: ${userId}`, - err, - ); - }); + googleCalendarSyncService.repairGoogleCalendarSync(userId).catch((err) => { + logger.error( + `Something went wrong with resyncing google calendars for user: ${userId}`, + err, + ); + }); res.status(Status.BAD_REQUEST).send({ message: "Full sync in progress." }); diff --git a/packages/backend/src/event/services/event.service.ts b/packages/backend/src/event/services/event.service.ts index 27433fc7d..1e2c454b0 100644 --- a/packages/backend/src/event/services/event.service.ts +++ b/packages/backend/src/event/services/event.service.ts @@ -29,7 +29,6 @@ import { type gSchema$Event } from "@core/types/gcal"; import { IDSchema } from "@core/types/type.utils"; import { type CompassEventRRule } from "@core/util/event/compass.event.rrule"; import { isInstance, parseCompassEventDate } from "@core/util/event/event.util"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { Collections } from "@backend/common/constants/collections"; import { EventError } from "@backend/common/errors/event/event.errors"; import { GenericError } from "@backend/common/errors/generic/generic.errors"; @@ -38,6 +37,7 @@ import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { reorderEvents } from "@backend/event/queries/event.queries"; import { getReadAllFilter } from "@backend/event/services/event.service.util"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; class EventService { /* diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 32cc03e0b..53a8a09ec 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -30,9 +30,9 @@ import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/e import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import * as syncQueries from "@backend/sync/util/sync.queries"; import { updateSync } from "@backend/sync/util/sync.queries"; import userService from "@backend/user/services/user.service"; @@ -140,7 +140,7 @@ describe("SyncController", () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "repairGoogleCalendarSync") .mockResolvedValue(); const watch = await mongoService.watch.findOne({ @@ -173,7 +173,7 @@ describe("SyncController", () => { ); expect(response.text).toEqual(""); - expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); + expect(restartSpy).toHaveBeenCalledWith(userId); restartSpy.mockRestore(); }); @@ -182,7 +182,7 @@ describe("SyncController", () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "repairGoogleCalendarSync") .mockImplementation(async () => { await userMetadataService.updateUserMetadata({ userId, @@ -225,7 +225,7 @@ describe("SyncController", () => { ); expect(restartSpy).toHaveBeenCalledTimes(1); - expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); + expect(restartSpy).toHaveBeenCalledWith(userId); restartSpy.mockRestore(); }); @@ -329,8 +329,8 @@ describe("SyncController", () => { expect(watch).toBeDefined(); expect(watch).not.toBeNull(); - const handleGcalNotificationSpy = jest - .spyOn(syncChannelService, "handleGcalNotification") + const handleGoogleWatchNotificationSpy = jest + .spyOn(googleWatchService, "handleGoogleWatchNotification") .mockRejectedValue(invalidGrant400Error); const pruneGoogleDataSpy = jest @@ -360,7 +360,7 @@ describe("SyncController", () => { expect(pruneGoogleDataSpy).toHaveBeenCalledWith(userId); expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); - handleGcalNotificationSpy.mockRestore(); + handleGoogleWatchNotificationSpy.mockRestore(); pruneGoogleDataSpy.mockRestore(); handleGoogleRevokedSpy.mockRestore(); }); @@ -377,8 +377,8 @@ describe("SyncController", () => { expect(watch).toBeDefined(); expect(watch).not.toBeNull(); - const handleGcalNotificationSpy = jest - .spyOn(syncChannelService, "handleGcalNotification") + const handleGoogleWatchNotificationSpy = jest + .spyOn(googleWatchService, "handleGoogleWatchNotification") .mockRejectedValue(missingRefreshTokenError); const pruneGoogleDataSpy = jest @@ -408,14 +408,14 @@ describe("SyncController", () => { expect(pruneGoogleDataSpy).toHaveBeenCalledWith(userId); expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); - handleGcalNotificationSpy.mockRestore(); + handleGoogleWatchNotificationSpy.mockRestore(); pruneGoogleDataSpy.mockRestore(); handleGoogleRevokedSpy.mockRestore(); }); it("should return GONE status when missing refresh token and no watch record found", async () => { - const handleGcalNotificationSpy = jest - .spyOn(syncChannelService, "handleGcalNotification") + const handleGoogleWatchNotificationSpy = jest + .spyOn(googleWatchService, "handleGoogleWatchNotification") .mockRejectedValue(missingRefreshTokenError); const response = await syncDriver.handleGoogleNotification( @@ -431,7 +431,7 @@ describe("SyncController", () => { expect(response.text).toBe("Missing refresh token"); - handleGcalNotificationSpy.mockRestore(); + handleGoogleWatchNotificationSpy.mockRestore(); }); }); diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 7099f0953..1d107df65 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -20,9 +20,9 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; -import { syncChannelMaintenanceService } from "@backend/sync/services/channel/sync-channel-maintenance.service"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; +import { googleWatchMaintenanceService } from "@backend/sync/services/watch/google-watch-maintenance.service"; import { getSync } from "@backend/sync/util/sync.queries"; import { isMissingGoogleRefreshToken } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; @@ -79,14 +79,12 @@ export class SyncController { userId: string, ): void => { // do not await this call - googleSyncLifecycleService - .restartGoogleCalendarSync(userId, { force: true }) - .catch((err) => { - logger.error( - `Something went wrong with resyncing google calendars for user: ${userId}`, - err, - ); - }); + googleCalendarSyncService.repairGoogleCalendarSync(userId).catch((err) => { + logger.error( + `Something went wrong with resyncing google calendars for user: ${userId}`, + err, + ); + }); res.status(Status.OK).send({ message: "Full sync in progress." }); }; @@ -134,14 +132,12 @@ export class SyncController { // When Google returns 410 (sync token invalid), the token may still exist // in the database but is no longer valid. assessGoogleMetadata checks token // existence, not validity, so we must force-restart directly. - googleSyncLifecycleService - .restartGoogleCalendarSync(userId, { force: true }) - .catch((err) => { - logger.error( - `Something went wrong with recovering google calendars for user: ${userId}`, - err, - ); - }); + googleCalendarSyncService.repairGoogleCalendarSync(userId).catch((err) => { + logger.error( + `Something went wrong with recovering google calendars for user: ${userId}`, + err, + ); + }); res.status(Status.NO_CONTENT).send(); }; @@ -205,7 +201,7 @@ export class SyncController { }); const response = - await syncChannelService.handleGcalNotification(syncPayload); + await googleWatchService.handleGoogleWatchNotification(syncPayload); res.promise(response); } catch (e) { @@ -276,7 +272,7 @@ export class SyncController { }); }); // 5 minutes timeout - const result = await syncChannelMaintenanceService.runMaintenance(); + const result = await googleWatchMaintenanceService.runMaintenance(); if (!res.headersSent) res.promise(result); } catch (e) { @@ -290,14 +286,16 @@ export class SyncController { const { force } = ImportGCalRequestSchema.parse(req.body); const isForce = force === true; - googleSyncLifecycleService - .restartGoogleCalendarSync(userId, { force: isForce }) - .catch((err) => { - logger.error( - `Something went wrong starting Google Calendar import for user: ${userId}`, - err, - ); - }); + const importPromise = isForce + ? googleCalendarSyncService.repairGoogleCalendarSync(userId) + : googleCalendarSyncService.startGoogleCalendarSyncIfNeeded(userId); + + importPromise.catch((err) => { + logger.error( + `Something went wrong starting Google Calendar import for user: ${userId}`, + err, + ); + }); res.status(Status.NO_CONTENT).send(); }; diff --git a/packages/backend/src/sync/controllers/sync.debug.controller.ts b/packages/backend/src/sync/controllers/sync.debug.controller.ts index f1b344b2a..6b0e051f2 100644 --- a/packages/backend/src/sync/controllers/sync.debug.controller.ts +++ b/packages/backend/src/sync/controllers/sync.debug.controller.ts @@ -1,16 +1,16 @@ import { type Request, type Response } from "express"; import { type SessionRequest } from "supertokens-node/framework/express"; import { type BaseError } from "@core/errors/errors.base"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { type Res_Promise, type SReqBody, } from "@backend/common/types/express.types"; import { sseServer } from "@backend/servers/sse/sse.server"; -import { syncChannelService } from "../services/channel/sync-channel.service"; -import { syncChannelMaintenanceService } from "../services/channel/sync-channel-maintenance.service"; -import { googleSyncLifecycleService } from "../services/lifecycle/google-sync-lifecycle.service"; -import { getSync } from "../util/sync.queries"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; +import { googleWatchMaintenanceService } from "@backend/sync/services/watch/google-watch-maintenance.service"; +import { getSync } from "@backend/sync/util/sync.queries"; class SyncDebugController { dispatchEventToClient = (_req: Request, res: Response) => { @@ -31,13 +31,17 @@ class SyncDebugController { } }; - importIncremental = async (req: SessionRequest, res: Res_Promise) => { + importLatestGoogleCalendarChanges = async ( + req: SessionRequest, + res: Res_Promise, + ) => { const userId = req.params["userId"]; if (!userId) { res.promise(Promise.reject({ error: "no userId param" })); return; } - const result = await googleSyncLifecycleService.importIncremental(userId); + const result = + await googleCalendarSyncService.importLatestGoogleCalendarChanges(userId); res.promise(result); }; @@ -56,7 +60,7 @@ class SyncDebugController { return; } - const result = await syncChannelMaintenanceService.runMaintenanceByUser( + const result = await googleWatchMaintenanceService.runMaintenanceByUser( userId, { dry, @@ -95,7 +99,7 @@ class SyncDebugController { const calendarId = req.body.calendarId; const gcal = await getGcalClient(userId); - const watchResult = await syncChannelService.startWatchingGcalEvents( + const watchResult = await googleWatchService.startEventWatch( userId, { gCalendarId: calendarId, @@ -118,7 +122,7 @@ class SyncDebugController { userId = req.session?.getUserId() as string; } - const stopResult = await syncChannelService.stopWatches(userId); + const stopResult = await googleWatchService.stopWatches(userId); res.promise(stopResult); } catch (e) { const _e = e as BaseError; @@ -135,7 +139,7 @@ class SyncDebugController { const channelId = req.body.channelId; const resourceId = req.body.resourceId; - const stopResult = await syncChannelService.stopWatch( + const stopResult = await googleWatchService.stopWatch( userId, channelId, resourceId, diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.test.ts similarity index 69% rename from packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts rename to packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.test.ts index c36415585..b0ea7edd7 100644 --- a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.test.ts +++ b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.test.ts @@ -7,20 +7,20 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import { sseServer } from "@backend/servers/sse/sse.server"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import * as syncImportService from "@backend/sync/services/import/sync.import"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; -describe("googleSyncLifecycleService", () => { +describe("googleCalendarSyncService", () => { beforeAll(initSupertokens); beforeEach(setupTestDb); beforeEach(cleanupCollections); afterEach(() => jest.restoreAllMocks()); afterAll(cleanupTestDb); - describe("importIncremental", () => { + describe("importLatestGoogleCalendarChanges", () => { it("emits INCREMENTAL operation when incremental import is ignored", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); @@ -31,7 +31,7 @@ describe("googleSyncLifecycleService", () => { data: { sync: { incrementalGCalSync: "COMPLETED" } }, }); - await googleSyncLifecycleService.importIncremental(userId); + await googleCalendarSyncService.importLatestGoogleCalendarChanges(userId); expect(importEndSpy).toHaveBeenCalledWith(userId, { operation: "INCREMENTAL", @@ -51,7 +51,7 @@ describe("googleSyncLifecycleService", () => { ReturnType >); - await googleSyncLifecycleService.importIncremental(userId); + await googleCalendarSyncService.importLatestGoogleCalendarChanges(userId); expect(importEndSpy).toHaveBeenCalledWith(userId, { operation: "INCREMENTAL", @@ -60,35 +60,41 @@ describe("googleSyncLifecycleService", () => { }); }); - describe("startGoogleCalendarSync", () => { + describe("initializeGoogleCalendarSync", () => { it("starts Google watches only after full import succeeds", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const callOrder: string[] = []; - const importFull = googleSyncLifecycleService.importFull; - const startWatching = syncChannelService.startWatchingGcalResources; + const startWatching = googleWatchService.startGoogleWatches; - jest - .spyOn(googleSyncLifecycleService, "importFull") - .mockImplementation(async (...args) => { + jest.spyOn(syncImportService, "createSyncImport").mockResolvedValue({ + importAllEvents: jest.fn().mockImplementation(async () => { callOrder.push("importFull"); - return importFull(...args); - }); + return { + nextSyncToken: "next-sync-token", + totalChanged: 0, + totalProcessed: 0, + totalSaved: 0, + }; + }), + } as unknown as Awaited< + ReturnType + >); jest - .spyOn(syncChannelService, "startWatchingGcalResources") + .spyOn(googleWatchService, "startGoogleWatches") .mockImplementation(async (...args) => { callOrder.push("startWatching"); return startWatching(...args); }); - await googleSyncLifecycleService.startGoogleCalendarSync(userId); + await googleCalendarSyncService.initializeGoogleCalendarSync(userId); expect(callOrder).toEqual(["importFull", "startWatching"]); }); }); - describe("restartGoogleCalendarSync", () => { - it("skips restart when import is completed and not forced", async () => { + describe("startGoogleCalendarSyncIfNeeded", () => { + it("skips sync setup when import is completed", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); @@ -100,11 +106,11 @@ describe("googleSyncLifecycleService", () => { const stopSpy = jest.spyOn(userService, "stopGoogleCalendarSync"); const startSpy = jest.spyOn( - googleSyncLifecycleService, - "startGoogleCalendarSync", + googleCalendarSyncService, + "initializeGoogleCalendarSync", ); - await googleSyncLifecycleService.restartGoogleCalendarSync(userId); + await googleCalendarSyncService.startGoogleCalendarSyncIfNeeded(userId); expect(stopSpy).not.toHaveBeenCalled(); expect(startSpy).not.toHaveBeenCalled(); @@ -114,8 +120,10 @@ describe("googleSyncLifecycleService", () => { message: `User ${userId} gcal import is in progress or completed, ignoring this request`, }); }); + }); - it("forces restart when import is completed", async () => { + describe("repairGoogleCalendarSync", () => { + it("forces sync setup when import is completed", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); @@ -128,12 +136,10 @@ describe("googleSyncLifecycleService", () => { .spyOn(userService, "stopGoogleCalendarSync") .mockResolvedValue(); const startSpy = jest - .spyOn(googleSyncLifecycleService, "startGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "initializeGoogleCalendarSync") .mockResolvedValue({ eventsCount: 0, calendarsCount: 0 }); - await googleSyncLifecycleService.restartGoogleCalendarSync(userId, { - force: true, - }); + await googleCalendarSyncService.repairGoogleCalendarSync(userId); expect(stopSpy).toHaveBeenCalledWith(userId); expect(startSpy).toHaveBeenCalledWith(userId); diff --git a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts similarity index 88% rename from packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts rename to packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts index 29b65704b..d093404e5 100644 --- a/packages/backend/src/sync/services/lifecycle/google-sync-lifecycle.service.ts +++ b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts @@ -5,20 +5,20 @@ import { shouldDoIncrementalGCalSync, shouldImportGCal, } from "@core/util/event/event.util"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import calendarService from "@backend/calendar/services/calendar.service"; import { getGoogleRepairErrorMessage } from "@backend/common/errors/integration/gcal/gcal.errors"; import { isInvalidGoogleToken } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; import { createSyncImport } from "@backend/sync/services/import/sync.import"; import compassGoogleMirrorService from "@backend/sync/services/outbound/compass-google-mirror.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { updateSync } from "@backend/sync/util/sync.queries"; import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; import userMetadataService from "@backend/user/services/user-metadata.service"; -const logger = Logger("app:google-sync-lifecycle.service"); +const logger = Logger("app:google-calendar-sync.service"); const activeFullSyncRestarts = new Set(); @@ -66,7 +66,7 @@ async function importFull( } } -async function importIncremental( +async function importLatestGoogleCalendarChanges( userId: string, gcal?: gCalendar, perPage = 1000, @@ -139,7 +139,7 @@ async function importIncremental( } } -async function restartGoogleCalendarSync( +async function runGoogleCalendarSyncSetup( userId: string, options: { force?: boolean } = {}, ) { @@ -188,7 +188,7 @@ async function restartGoogleCalendarSync( await userService.stopGoogleCalendarSync(userId); const importResults = - await googleSyncLifecycleService.startGoogleCalendarSync(userId); + await googleCalendarSyncService.initializeGoogleCalendarSync(userId); await compassGoogleMirrorService .syncCompassEventsToGoogle(userId) @@ -247,7 +247,7 @@ async function restartGoogleCalendarSync( } } -async function startGoogleCalendarSync( +async function initializeGoogleCalendarSync( user: string, ): Promise<{ eventsCount: number; calendarsCount: number }> { const gcal = await getGcalClient(user); @@ -261,14 +261,10 @@ async function startGoogleCalendarSync( .map(({ id }) => id) .filter((id): id is string => id !== undefined && id !== null); - const importResults = await googleSyncLifecycleService.importFull( - gcal, - gCalendarIds, - user, - ); + const importResults = await importFull(gcal, gCalendarIds, user); if (isUsingGcalWebhookHttps()) { - await syncChannelService.startWatchingGcalResources( + await googleWatchService.startGoogleWatches( user, [ { gCalendarId: Resource_Sync.CALENDAR }, @@ -289,9 +285,17 @@ async function startGoogleCalendarSync( }; } -export const googleSyncLifecycleService = { - importFull, - importIncremental, - restartGoogleCalendarSync, - startGoogleCalendarSync, +async function repairGoogleCalendarSync(userId: string) { + return runGoogleCalendarSyncSetup(userId, { force: true }); +} + +async function startGoogleCalendarSyncIfNeeded(userId: string) { + return runGoogleCalendarSyncSetup(userId); +} + +export const googleCalendarSyncService = { + importLatestGoogleCalendarChanges, + initializeGoogleCalendarSync, + repairGoogleCalendarSync, + startGoogleCalendarSyncIfNeeded, }; diff --git a/packages/backend/src/auth/services/google/clients/google.calendar.client.test.ts b/packages/backend/src/sync/services/google-calendar-sync/google.calendar.client.test.ts similarity index 95% rename from packages/backend/src/auth/services/google/clients/google.calendar.client.test.ts rename to packages/backend/src/sync/services/google-calendar-sync/google.calendar.client.test.ts index dde693972..379500df7 100644 --- a/packages/backend/src/auth/services/google/clients/google.calendar.client.test.ts +++ b/packages/backend/src/sync/services/google-calendar-sync/google.calendar.client.test.ts @@ -2,8 +2,8 @@ import { faker } from "@faker-js/faker"; import { GaxiosError } from "gaxios"; import { ObjectId } from "mongodb"; import { type Schema_User } from "@core/types/user.types"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { UserError } from "@backend/common/errors/user/user.errors"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; jest.mock("@backend/user/queries/user.queries", () => ({ diff --git a/packages/backend/src/auth/services/google/clients/google.calendar.client.ts b/packages/backend/src/sync/services/google-calendar-sync/google.calendar.client.ts similarity index 97% rename from packages/backend/src/auth/services/google/clients/google.calendar.client.ts rename to packages/backend/src/sync/services/google-calendar-sync/google.calendar.client.ts index f03fd97aa..4d8ba86c0 100644 --- a/packages/backend/src/auth/services/google/clients/google.calendar.client.ts +++ b/packages/backend/src/sync/services/google-calendar-sync/google.calendar.client.ts @@ -5,10 +5,10 @@ import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { type gCalendar } from "@core/types/gcal"; import { type Schema_User } from "@core/types/user.types"; +import GoogleOAuthClient from "@backend/auth/services/google/clients/google.oauth.client"; import { error } from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; -import GoogleOAuthClient from "./google.oauth.client"; const logger = Logger("app:google.calendar.service"); diff --git a/packages/backend/src/sync/services/import/sync.import.ts b/packages/backend/src/sync/services/import/sync.import.ts index 14cfff750..bf4321d8d 100644 --- a/packages/backend/src/sync/services/import/sync.import.ts +++ b/packages/backend/src/sync/services/import/sync.import.ts @@ -16,7 +16,6 @@ import { } from "@core/types/gcal"; import { Resource_Sync, type SyncDetails } from "@core/types/sync.types"; import { isBaseGCalEvent } from "@core/util/event/gcal.event.util"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { Collections } from "@backend/common/constants/collections"; import { EventError } from "@backend/common/errors/event/event.errors"; import { GenericError } from "@backend/common/errors/generic/generic.errors"; @@ -26,10 +25,11 @@ import { SyncError } from "@backend/common/errors/sync/sync.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; import { getGcalWebhookBaseURL } from "@backend/common/util/api-base-url.util"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; import { type EventsToModify } from "@backend/sync/services/import/sync.import.types"; import { organizeGcalEventsByType } from "@backend/sync/services/import/sync.import.util"; import { getCalendarsToSync } from "@backend/sync/services/init/sync.init"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { getGCalEventsSyncPageToken, getSync, @@ -547,7 +547,7 @@ export class SyncImport { undefined, ); - await syncChannelService.startWatchingGcalResources( + await googleWatchService.startGoogleWatches( userId, [ ...gCalendarIds.map((gCalendarId) => ({ gCalendarId })), diff --git a/packages/backend/src/sync/services/init/sync.init.test.ts b/packages/backend/src/sync/services/init/sync.init.test.ts index f3e223cc1..7f81b249e 100644 --- a/packages/backend/src/sync/services/init/sync.init.test.ts +++ b/packages/backend/src/sync/services/init/sync.init.test.ts @@ -7,8 +7,8 @@ import { cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import gcalService from "@backend/common/services/gcal/gcal.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; import { getCalendarsToSync } from "@backend/sync/services/init/sync.init"; describe("getCalendarsToSync", () => { diff --git a/packages/backend/src/sync/services/maintain/sync.maintenance.ts b/packages/backend/src/sync/services/watch/google-watch-maintenance.planner.ts similarity index 88% rename from packages/backend/src/sync/services/maintain/sync.maintenance.ts rename to packages/backend/src/sync/services/watch/google-watch-maintenance.planner.ts index b6d4aac70..3577f6bf8 100644 --- a/packages/backend/src/sync/services/maintain/sync.maintenance.ts +++ b/packages/backend/src/sync/services/watch/google-watch-maintenance.planner.ts @@ -3,19 +3,19 @@ import { Logger } from "@core/logger/winston.logger"; import { type Result_Watch_Stop } from "@core/types/sync.types"; import { type Schema_Watch } from "@core/types/watch.types"; import dayjs from "@core/util/date/dayjs"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { isFullSyncRequired, isInvalidGoogleToken, } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { hasUpdatedCompassEventRecently } from "@backend/sync/util/sync.queries"; import { syncExpired, syncExpiresSoon } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; -const logger = Logger("app:sync.maintenance"); +const logger = Logger("app:google-watch-maintenance.planner"); const getActiveDeadline = () => { const deadlineDays = 14; @@ -75,7 +75,7 @@ export const pruneSync = async ( try { const results = await Promise.all( payload.map(({ _id, resourceId }) => - syncChannelService.stopWatch( + googleWatchService.stopWatch( user, _id.toString(), resourceId, @@ -119,7 +119,7 @@ export const refreshWatch = async ( const refreshesByUser = await Promise.all( r.payload.map(async ({ _id, user, expiration, ...syncPayload }) => { - const _refresh = await syncChannelService.refreshWatch( + const _refresh = await googleWatchService.refreshWatch( user, { ...syncPayload, @@ -145,9 +145,7 @@ export const refreshWatch = async ( }; } catch (e) { if (isFullSyncRequired(e as Error)) { - void googleSyncLifecycleService.restartGoogleCalendarSync(r.user, { - force: true, - }); + void googleCalendarSyncService.repairGoogleCalendarSync(r.user); resynced = true; } else { logger.error( diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.test.ts similarity index 83% rename from packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts rename to packages/backend/src/sync/services/watch/google-watch-maintenance.service.test.ts index 322b16477..ecf0e12c9 100644 --- a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.test.ts +++ b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.test.ts @@ -7,9 +7,9 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; -import { syncChannelMaintenanceService } from "@backend/sync/services/channel/sync-channel-maintenance.service"; +import { googleWatchMaintenanceService } from "@backend/sync/services/watch/google-watch-maintenance.service"; -describe("syncChannelMaintenanceService", () => { +describe("googleWatchMaintenanceService", () => { beforeAll(initSupertokens); beforeEach(setupTestDb); beforeEach(cleanupCollections); @@ -28,7 +28,7 @@ describe("syncChannelMaintenanceService", () => { }; await mongoService.watch.insertOne(watch); - const result = await syncChannelMaintenanceService.runMaintenanceByUser( + const result = await googleWatchMaintenanceService.runMaintenanceByUser( userId, { dry: true }, ); diff --git a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts similarity index 94% rename from packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts rename to packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts index c590a36a4..30526e999 100644 --- a/packages/backend/src/sync/services/channel/sync-channel-maintenance.service.ts +++ b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts @@ -6,11 +6,11 @@ import { prepWatchMaintenanceForUser, pruneSync, refreshWatch, -} from "@backend/sync/services/maintain/sync.maintenance"; +} from "@backend/sync/services/watch/google-watch-maintenance.planner"; import { createConcurrencyLimiter } from "@backend/sync/util/sync.util"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; -const logger = Logger("app:sync-channel-maintenance.service"); +const logger = Logger("app:google-watch-maintenance.service"); async function runMaintenance() { const cursor = mongoService.user.find().batchSize(MONGO_BATCH_SIZE); @@ -33,7 +33,7 @@ async function runMaintenance() { const run = await Promise.all( users.map((user) => limit(() => - syncChannelMaintenanceService + googleWatchMaintenanceService .runMaintenanceByUser(user.toString(), { log: false, }) @@ -134,7 +134,7 @@ async function runMaintenanceByUser( }; } -export const syncChannelMaintenanceService = { +export const googleWatchMaintenanceService = { runMaintenance, runMaintenanceByUser, }; diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.test.ts b/packages/backend/src/sync/services/watch/google-watch.service.test.ts similarity index 89% rename from packages/backend/src/sync/services/channel/sync-channel.service.test.ts rename to packages/backend/src/sync/services/watch/google-watch.service.test.ts index 91bb93d71..d1909aa2e 100644 --- a/packages/backend/src/sync/services/channel/sync-channel.service.test.ts +++ b/packages/backend/src/sync/services/watch/google-watch.service.test.ts @@ -13,7 +13,7 @@ import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; jest.mock("@backend/sync/util/sync.util", () => { @@ -39,7 +39,7 @@ const createWatch = async (user: string) => { return watch; }; -describe("syncChannelService", () => { +describe("googleWatchService", () => { beforeAll(initSupertokens); beforeEach(setupTestDb); beforeEach(cleanupCollections); @@ -52,7 +52,7 @@ describe("syncChannelService", () => { const firstUserWatch = await createWatch(firstUser._id.toString()); const secondUserWatch = await createWatch(secondUser._id.toString()); - const deleted = await syncChannelService.deleteWatchesByUser( + const deleted = await googleWatchService.deleteWatchesByUser( firstUser._id.toString(), ); @@ -79,7 +79,7 @@ describe("syncChannelService", () => { .mockRejectedValue(invalidGrant400Error); await expect( - syncChannelService.stopWatch( + googleWatchService.stopWatch( user._id.toString(), watch._id.toString(), watch.resourceId, @@ -100,7 +100,7 @@ describe("syncChannelService", () => { ); await expect( - syncChannelService.stopWatch( + googleWatchService.stopWatch( user._id.toString(), watch._id.toString(), watch.resourceId, @@ -114,11 +114,11 @@ describe("syncChannelService", () => { it("ignores expired notifications when no local watch record remains", async () => { const cleanupSpy = jest - .spyOn(syncChannelService, "cleanupStaleWatchChannel") + .spyOn(googleWatchService, "cleanupStaleWatch") .mockResolvedValue(false); await expect( - syncChannelService.handleGcalNotification({ + googleWatchService.handleGoogleWatchNotification({ resource: Resource_Sync.EVENTS, channelId: new ObjectId(), resourceId: faker.string.uuid(), @@ -133,16 +133,16 @@ describe("syncChannelService", () => { it("skips direct Google watch setup when the Google webhook URL is not HTTPS", async () => { (isUsingGcalWebhookHttps as jest.Mock).mockReturnValue(false); const startCalendarWatchSpy = jest.spyOn( - syncChannelService, - "startWatchingGcalCalendars", + googleWatchService, + "startCalendarListWatch", ); const startEventWatchSpy = jest.spyOn( - syncChannelService, - "startWatchingGcalEvents", + googleWatchService, + "startEventWatch", ); await expect( - syncChannelService.startWatchingGcalResources( + googleWatchService.startGoogleWatches( "507f1f77bcf86cd799439011", [{ gCalendarId: Resource_Sync.CALENDAR }, { gCalendarId: "primary" }], {} as never, diff --git a/packages/backend/src/sync/services/channel/sync-channel.service.ts b/packages/backend/src/sync/services/watch/google-watch.service.ts similarity index 90% rename from packages/backend/src/sync/services/channel/sync-channel.service.ts rename to packages/backend/src/sync/services/watch/google-watch.service.ts index adf335ebd..b91d5efd8 100644 --- a/packages/backend/src/sync/services/channel/sync-channel.service.ts +++ b/packages/backend/src/sync/services/watch/google-watch.service.ts @@ -10,7 +10,6 @@ import { } from "@core/types/sync.types"; import { ExpirationDateSchema } from "@core/types/type.utils"; import { WatchSchema } from "@core/types/watch.types"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { error } from "@backend/common/errors/handlers/error.handler"; import { GcalError } from "@backend/common/errors/integration/gcal/gcal.errors"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; @@ -23,6 +22,7 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { sseServer } from "@backend/servers/sse/sse.server"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; import { getSync, @@ -35,7 +35,7 @@ import { } from "@backend/sync/util/sync.util"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; -const logger = Logger("app:sync-channel.service"); +const logger = Logger("app:google-watch.service"); async function deleteWatchesByUser( user: string, @@ -88,7 +88,7 @@ async function prepareStopWatches( }; } -async function cleanupStaleWatchChannel({ +async function cleanupStaleWatch({ channelId, resourceId, }: Payload_Sync_Notif): Promise { @@ -106,7 +106,7 @@ async function cleanupStaleWatchChannel({ } try { - await syncChannelService.stopWatch( + await googleWatchService.stopWatch( channel.user, channel._id.toString(), channel.resourceId, @@ -127,7 +127,7 @@ async function cleanupStaleWatchChannel({ } } -async function handleGcalNotification(payload: Payload_Sync_Notif) { +async function handleGoogleWatchNotification(payload: Payload_Sync_Notif) { const { channelId, resourceId, resourceState, resource } = payload; const { expiration } = payload; @@ -146,8 +146,7 @@ async function handleGcalNotification(payload: Payload_Sync_Notif) { }); if (!watch) { - const cleanedUp = - await syncChannelService.cleanupStaleWatchChannel(payload); + const cleanedUp = await googleWatchService.cleanupStaleWatch(payload); if (cleanedUp) return "IGNORED"; @@ -161,8 +160,7 @@ async function handleGcalNotification(payload: Payload_Sync_Notif) { const sync = await getSync({ userId: watch.user, resource }); if (!sync) { - const cleanedUp = - await syncChannelService.cleanupStaleWatchChannel(payload); + const cleanedUp = await googleWatchService.cleanupStaleWatch(payload); if (cleanedUp) return "IGNORED"; @@ -219,7 +217,7 @@ async function refreshWatch( const watchExists = payload.channelId && payload.resourceId; if (watchExists) { - await syncChannelService.stopWatch( + await googleWatchService.stopWatch( userId, payload.channelId, payload.resourceId, @@ -227,7 +225,7 @@ async function refreshWatch( ); } - const watchResult = await syncChannelService.startWatchingGcalResources( + const watchResult = await googleWatchService.startGoogleWatches( userId, [{ gCalendarId: payload.gCalendarId, quotaUser: payload.quotaUser }], gcal, @@ -236,7 +234,7 @@ async function refreshWatch( return watchResult[0]; } -async function startWatchingGcalCalendars( +async function startCalendarListWatch( user: string, params: Pick, gcal: gCalendar, @@ -286,7 +284,7 @@ async function startWatchingGcalCalendars( }), ) .catch(async (error) => { - await syncChannelService.stopWatch(user, channelId, resourceId, gcal); + await googleWatchService.stopWatch(user, channelId, resourceId, gcal); throw error; }); @@ -299,7 +297,7 @@ async function startWatchingGcalCalendars( } } -async function startWatchingGcalEvents( +async function startEventWatch( user: string, params: Pick, gcal: gCalendar, @@ -346,7 +344,7 @@ async function startWatchingGcalEvents( }), ) .catch(async (error) => { - await syncChannelService.stopWatch(user, channelId, resourceId, gcal); + await googleWatchService.stopWatch(user, channelId, resourceId, gcal); throw error; }); @@ -359,7 +357,7 @@ async function startWatchingGcalEvents( } } -async function startWatchingGcalResources( +async function startGoogleWatches( userId: string, watchParams: Pick[], gcal: gCalendar, @@ -371,14 +369,10 @@ async function startWatchingGcalResources( return Promise.all( watchParams.map(async (params) => { if (params.gCalendarId === (Resource_Sync.CALENDAR as string)) { - return syncChannelService.startWatchingGcalCalendars( - userId, - params, - gcal, - ); + return googleWatchService.startCalendarListWatch(userId, params, gcal); } - return syncChannelService.startWatchingGcalEvents(userId, params, gcal); + return googleWatchService.startEventWatch(userId, params, gcal); }), ).then((results) => results.filter((r) => r !== undefined)); } @@ -459,7 +453,7 @@ async function stopWatches( ); const result = await Promise.all( prepared.watches.map(async ({ _id, resourceId }) => - syncChannelService + googleWatchService .stopWatch( user, _id.toString(), @@ -487,14 +481,14 @@ async function stopWatches( return stopped; } -export const syncChannelService = { +export const googleWatchService = { deleteWatchesByUser, - cleanupStaleWatchChannel, - handleGcalNotification, + cleanupStaleWatch, + handleGoogleWatchNotification, refreshWatch, - startWatchingGcalCalendars, - startWatchingGcalEvents, - startWatchingGcalResources, + startCalendarListWatch, + startEventWatch, + startGoogleWatches, stopWatch, stopWatches, }; diff --git a/packages/backend/src/sync/sync.routes.config.ts b/packages/backend/src/sync/sync.routes.config.ts index c3c3b54a1..591e3e3b4 100644 --- a/packages/backend/src/sync/sync.routes.config.ts +++ b/packages/backend/src/sync/sync.routes.config.ts @@ -53,7 +53,7 @@ export class SyncRoutes extends CommonRoutesConfig { .post([ authMiddleware.verifyIsFromCompass, requireGoogleConnectionFrom("userId"), - syncDebugController.importIncremental, + syncDebugController.importLatestGoogleCalendarChanges, ]); this.app diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index 72823c84a..d6c205b25 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -8,7 +8,7 @@ import { setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import { isUsingGcalWebhookHttps } from "@backend/sync/util/sync.util"; // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- mock factory spreads requireActual @@ -121,7 +121,7 @@ describe("UserMetadataService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const metadata = await driver.fetchUserMetadata(userId); @@ -150,7 +150,7 @@ describe("UserMetadataService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(googleSyncLifecycleService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); await driver.updateUserMetadata({ diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index cea1b8785..46984b408 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -19,8 +19,8 @@ import * as supertokensMiddleware from "@backend/common/middleware/supertokens.m import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; import priorityService from "@backend/priority/services/priority.service"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; -import { googleSyncLifecycleService } from "@backend/sync/services/lifecycle/google-sync-lifecycle.service"; +import { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { type Summary_Delete } from "@backend/user/types/user.types"; @@ -259,7 +259,7 @@ describe("UserService", () => { await priorityService.createDefaultPriorities(userId); await SyncDriver.createSync(storedUser!, true); - await googleSyncLifecycleService.startGoogleCalendarSync(userId); + await googleCalendarSyncService.initializeGoogleCalendarSync(userId); const summary: Summary_Delete = await userService.deleteCompassDataForUser(userId, false); @@ -536,7 +536,7 @@ describe("UserService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - await googleSyncLifecycleService.startGoogleCalendarSync(userId); + await googleCalendarSyncService.initializeGoogleCalendarSync(userId); const listCalendarsForUser = calendarService.getByUser.bind(calendarService); @@ -575,7 +575,7 @@ describe("UserService", () => { it("skips Google metadata updates for email/password-only users", async () => { const user = await UserDriver.createUser({ withGoogle: false }); const stopWatchesSpy = jest - .spyOn(syncChannelService, "stopWatches") + .spyOn(googleWatchService, "stopWatches") .mockResolvedValue([]); const updateMetadataSpy = jest.spyOn( userMetadataService, @@ -593,7 +593,7 @@ describe("UserService", () => { it("updates Google metadata and stops watches for last active Google sessions", async () => { const user = await UserDriver.createUser(); const stopWatchesSpy = jest - .spyOn(syncChannelService, "stopWatches") + .spyOn(googleWatchService, "stopWatches") .mockResolvedValue([]); const updateMetadataSpy = jest .spyOn(userMetadataService, "updateUserMetadata") @@ -643,15 +643,15 @@ describe("UserService", () => { it("stops sync, clears the Google refresh token, and resets sync metadata", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - const stopWatchesSpy = jest.spyOn(syncChannelService, "stopWatches"); + const stopWatchesSpy = jest.spyOn(googleWatchService, "stopWatches"); const deleteWatchesSpy = jest.spyOn( - syncChannelService, + googleWatchService, "deleteWatchesByUser", ); expect(user.google).toBeDefined(); - await googleSyncLifecycleService.startGoogleCalendarSync(userId); + await googleCalendarSyncService.initializeGoogleCalendarSync(userId); const eventCountBefore = await mongoService.event.countDocuments({ user: userId, diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index bdd257a70..5894089fb 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -20,8 +20,8 @@ import { normalizeEmail } from "@backend/common/helpers/email.util"; import mongoService from "@backend/common/services/mongo.service"; import eventService from "@backend/event/services/event.service"; import priorityService from "@backend/priority/services/priority.service"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; import syncRecords from "@backend/sync/services/records/sync.records"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { findCanonicalCompassUser } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { @@ -199,7 +199,7 @@ class UserService { summary.events = events.deletedCount; if (gcalAccess) { - const watches = await syncChannelService.stopWatches( + const watches = await googleWatchService.stopWatches( userId, undefined, new ObjectId().toString(), @@ -263,9 +263,9 @@ class UserService { await eventService.deleteByIntegration("google", userId); if (skipGoogleWatchStop) { - await syncChannelService.deleteWatchesByUser(userId); + await googleWatchService.deleteWatchesByUser(userId); } else { - await syncChannelService.stopWatches(userId); + await googleWatchService.stopWatches(userId); } await syncRecords.deleteByIntegration("google", userId); }; @@ -294,7 +294,7 @@ class UserService { } if (options.isLastActiveSession) { - await syncChannelService.stopWatches(userId); + await googleWatchService.stopWatches(userId); } }; diff --git a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts index 2acb585b6..13864a7c0 100644 --- a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts +++ b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts @@ -5,11 +5,11 @@ import { z } from "zod/v4"; import { Resource_Sync } from "@core/types/sync.types"; import { ExpirationDateSchema } from "@core/types/type.utils"; import { type Schema_Watch, WatchSchema } from "@core/types/watch.types"; -import { getGcalClient } from "@backend/auth/services/google/clients/google.calendar.client"; import { MONGO_BATCH_SIZE } from "@backend/common/constants/backend.constants"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import { syncChannelService } from "@backend/sync/services/channel/sync-channel.service"; +import { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { getChannelExpiration } from "@backend/sync/util/sync.util"; export default class Migration implements RunnableMigration { @@ -61,7 +61,7 @@ export default class Migration implements RunnableMigration { return [parsed.data]; }); - const gcal = await getGcalClient(syncDoc.user).catch((err) => { + const gcal = await getGcalClient(syncDoc.user).catch((err: unknown) => { logger.error( `Failed to get gcal client for user ${syncDoc.user}: ${err}`, ); @@ -76,7 +76,7 @@ export default class Migration implements RunnableMigration { await Promise.allSettled([ ...syncDocs.map(async (s) => { - await syncChannelService + await googleWatchService .stopWatch(syncDoc.user, s.channelId, s.resourceId, gcal, quotaUser) .catch(logger.error);