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/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 cbf5d62d8..9f236db98 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/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 fdc49f5f6..707032f9e 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` +- 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` +- Google/Compass event processors: `packages/backend/src/sync/services/sync` ## Users / Metadata / Priority 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/google-sync-and-sse-flow.md b/docs/features/google-sync-and-sse-flow.md index 8ca8066fa..18dbfb1e8 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/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. `SyncService.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,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/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/google-calendar-sync/google-calendar-sync.service.ts` +- `packages/backend/src/sync/services/outbound/compass-google-mirror.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 `SyncService` 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/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`: 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 b7d1f9500..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 syncService from "@backend/sync/services/sync.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 syncService.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/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 f060cb31d..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,15 +12,40 @@ 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 { 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"; +import { googleAuthService } from "./google.auth.service"; import { type AuthDecision, 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", @@ -32,7 +57,7 @@ jest.mock("@backend/auth/services/google/util/google.auth.util", () => { }; }); -describe("GoogleAuthService", () => { +describe("googleAuthService", () => { beforeEach(setupTestDb); beforeEach(cleanupCollections); afterAll(cleanupTestDb); @@ -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,9 +232,75 @@ 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", () => { + 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(); @@ -220,7 +314,7 @@ describe("GoogleAuthService", () => { refresh_token: faker.string.uuid(), }; const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); await userService.pruneGoogleData(compassUserId); @@ -263,7 +357,7 @@ describe("GoogleAuthService", () => { }; const restartError = new Error("sync failed"); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockRejectedValue(restartError); await userService.pruneGoogleData(compassUserId); @@ -282,6 +376,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(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") + .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(googleCalendarSyncService, "importLatestGoogleCalendarChanges") + .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", () => { @@ -300,7 +469,7 @@ describe("GoogleAuthService", () => { }); const refreshToken = faker.string.uuid(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -345,7 +514,7 @@ describe("GoogleAuthService", () => { withGoogle: false, }); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -384,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(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const exchangeSpy = jest .spyOn(GoogleOAuthClient.prototype, "exchangeAuthCode") @@ -434,7 +603,7 @@ describe("GoogleAuthService", () => { .spyOn(EmailService, "tagNewUserIfEnabled") .mockResolvedValue(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "startGoogleCalendarSyncIfNeeded") .mockResolvedValue(); const result = await googleAuthService.googleSignup( @@ -467,7 +636,7 @@ describe("GoogleAuthService", () => { } as TokenPayload; const refreshToken = faker.string.uuid(); const restartSpy = jest - .spyOn(syncService, "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 d96b38d30..160024b49 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"; @@ -14,268 +15,388 @@ 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 { 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"; -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; -class GoogleAuthService { - private persistGoogleConnection = async ( - compassUserId: string, - gUser: TokenPayload, - refreshToken: string, - ) => { - await userService.reconnectGoogleCredentials( - compassUserId, - gUser, - refreshToken, - ); +// Keep auth traces searchable without putting raw user identifiers in production logs. +function getTraceId(value: string | null | undefined): string | undefined { + const normalizedValue = value?.trim(); - await userMetadataService.updateUserMetadata({ - userId: compassUserId, - data: { - sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, - }, - }); + if (!normalizedValue) { + return undefined; + } - this.restartGoogleCalendarSyncInBackground(compassUserId); + return createHmac("sha256", ENV.TOKEN_COMPASS_SYNC) + .update(normalizedValue) + .digest("hex") + .slice(0, AUTH_TRACE_ID_LENGTH); +} - return { cUserId: compassUserId }; +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, + gUser: TokenPayload, + refreshToken: string, +) { + await userService.reconnectGoogleCredentials( + compassUserId, + gUser, + refreshToken, + ); + + await userMetadataService.updateUserMetadata({ + userId: compassUserId, + data: { + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + }, + }); + + startGoogleCalendarSyncIfNeededInBackground(compassUserId); + + return { cUserId: compassUserId }; +} - private restartGoogleCalendarSyncInBackground = (cUserId: string) => { - syncService.restartGoogleCalendarSync(cUserId).catch((err) => { +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", + ); + } + + await userService.refreshGoogleProfile(cUserId, gUser); + + await userMetadataService.updateUserMetadata({ + userId: cUserId, + data: { + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + }, + }); + + startGoogleCalendarSyncIfNeededInBackground(cUserId); + + return { cUserId }; +} + +function startGoogleCalendarSyncIfNeededInBackground(cUserId: string) { + googleCalendarSyncService + .startGoogleCalendarSyncIfNeeded(cUserId) + .catch((err) => { logger.error( `Something went wrong with starting calendar sync for user ${cUserId}`, err, ); }); - }; +} - 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, - ); +async function googleSignup( + gUser: TokenPayload, + refreshToken: string, + userId: string, +) { + const session = await mongoService.startSession(); - await userMetadataService.updateUserMetadata({ - userId: cUser.user.userId, - data: { - skipOnboarding: false, - sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + 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, }, - }); - - await EmailService.tagNewUserIfEnabled(cUser.user, cUser.isNewUser); + }, + transactionSession, + ); - return { cUserId: cUser.user.userId }; + await userMetadataService.updateUserMetadata({ + userId: cUser.user.userId, + data: { + skipOnboarding: false, + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, + }, }); - this.restartGoogleCalendarSyncInBackground(user.cUserId); + await EmailService.tagNewUserIfEnabled(cUser.user, cUser.isNewUser); + + return { cUserId: cUser.user.userId }; + }); - return user; + startGoogleCalendarSyncIfNeededInBackground(user.cUserId); + + return user; +} + +async function repairGoogleConnection( + compassUserId: string, + gUser: TokenPayload, + oAuthTokens: Pick, +) { + if (!oAuthTokens.refresh_token) { + return persistStoredGoogleConnection(compassUserId, gUser); } - async repairGoogleConnection( - compassUserId: string, - gUser: TokenPayload, - oAuthTokens: Pick, - ) { - const { - cUserId, - gUser: validatedGUser, - refreshToken, - } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); + const { + cUserId, + gUser: validatedGUser, + refreshToken, + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); - return this.persistGoogleConnection(cUserId, validatedGUser, refreshToken); + return persistGoogleConnection(cUserId, validatedGUser, refreshToken); +} + +async function getConnectedCompassUserId( + googleUserId: string | null | undefined, +): Promise { + if (!googleUserId) { + return null; } - async getConnectedCompassUserId( - googleUserId: string | null | undefined, - ): Promise { - if (!googleUserId) { - return null; - } + const user = await findCompassUserBy("google.googleId", googleUserId); + return user?._id.toString() ?? null; +} - const user = await findCompassUserBy("google.googleId", googleUserId); - return user?._id.toString() ?? null; +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 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 this.getConnectedCompassUserId( - validatedGUser.sub, - ); + const currentUser = await findCompassUserBy("_id", cUserId); - if (existingCompassUserId && existingCompassUserId !== cUserId) { - throw error( - AuthError.GoogleAccountAlreadyConnected, - "User not connected", - ); - } + if (!currentUser) { + throw error(UserError.UserNotFound, "User not connected"); + } - const currentUser = await findCompassUserBy("_id", cUserId); + if ( + !validatedGUser.email || + normalizeEmail(validatedGUser.email) !== normalizeEmail(currentUser.email) + ) { + throw error(AuthError.GoogleConnectEmailMismatch, "User not connected"); + } - if (!currentUser) { - throw error(UserError.UserNotFound, "User not connected"); - } + return persistGoogleConnection(cUserId, validatedGUser, refreshToken); +} - if ( - !validatedGUser.email || - normalizeEmail(validatedGUser.email) !== normalizeEmail(currentUser.email) - ) { - throw error(AuthError.GoogleConnectEmailMismatch, "User not connected"); - } +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(), + }; - return this.persistGoogleConnection(cUserId, validatedGUser, refreshToken); + if (refreshToken) { + update["google.gRefreshToken"] = refreshToken; } - async googleSignin( - gUser: TokenPayload, - oAuthTokens: Pick, - ) { - 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 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); + + googleCalendarSyncService + .importLatestGoogleCalendarChanges( + 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" } }, + }); + + startGoogleCalendarSyncIfNeededInBackground(cUserId); + return; + } + + logger.error("Error during incremental sync:", err); }); - const user = await mongoService.user.findOneAndUpdate( - { "google.googleId": gUserId }, - { - $set: { - "google.gRefreshToken": refreshToken, - "google.picture": gUser.picture || "not provided", - lastLoggedInAt: new Date(), - }, - }, - { returnDocument: "after" }, - ); + return { cUserId }; +} - const cUserId = zObjectId - .parse(user?._id, { error: () => "Invalid credentials" }) - .toString(); - - const googleOAuthClient = new GoogleOAuthClient(); - googleOAuthClient.oauthClient.setCredentials(oAuthTokens); - - syncService - .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, + // Determine auth mode based on server-side state + const decision = await determineGoogleAuthMode( + googleUserId, + providerUser.email, + createdNewRecipeUser, + ); + + logger.info( + "google_auth_decision", + getGoogleAuthDecisionTrace({ createdNewRecipeUser, - recipeUserId, + decision, + googleUserId, loginMethodsLength, - } = success; + providerEmail: providerUser.email, + }), + ); + + 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 googleUserId = providerUser.sub; - if (!googleUserId) { - throw new Error("Google user ID (sub) is required"); - } + const refreshToken = oAuthTokens.refresh_token; + if (!refreshToken) { + throw new Error("Refresh token expected for new user sign-up"); + } - // Determine auth mode based on server-side state - const decision = await determineGoogleAuthMode( - googleUserId, - providerUser.email, - createdNewRecipeUser, - ); + await googleAuthService.googleSignup( + providerUser, + refreshToken, + recipeUserId, + ); + return; + } - 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; + 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"); } - case "RECONNECT_REPAIR": { - // User exists but needs repair (missing refresh token or unhealthy sync) - await this.repairGoogleConnection( - decision.compassUserId!, - providerUser, - oAuthTokens, - ); - return; - } + await googleAuthService.repairGoogleConnection( + compassUserId, + providerUser, + oAuthTokens, + ); + return; + } - case "SIGNIN_INCREMENTAL": { - // Healthy returning user - attempt incremental sync - await this.googleSignin(providerUser, oAuthTokens); - return; - } + case "SIGNIN_INCREMENTAL": { + // Healthy returning user - attempt incremental sync + await googleAuthService.googleSignin(providerUser, oAuthTokens); + return; } } } -export default new GoogleAuthService(); +export const googleAuthService = { + googleSignup, + repairGoogleConnection, + getConnectedCompassUserId, + connectGoogleToCurrentUser, + googleSignin, + handleGoogleAuth, +}; 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/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 815fa57ac..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 syncService from "@backend/sync/services/sync.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)) { - syncService - .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/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/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) { 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 b01d22992..53a8a09ec 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 { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; -import syncService from "@backend/sync/services/sync.service"; +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"; @@ -139,7 +140,7 @@ describe("SyncController", () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "repairGoogleCalendarSync") .mockResolvedValue(); const watch = await mongoService.watch.findOne({ @@ -172,7 +173,7 @@ describe("SyncController", () => { ); expect(response.text).toEqual(""); - expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); + expect(restartSpy).toHaveBeenCalledWith(userId); restartSpy.mockRestore(); }); @@ -181,7 +182,7 @@ describe("SyncController", () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest - .spyOn(syncService, "restartGoogleCalendarSync") + .spyOn(googleCalendarSyncService, "repairGoogleCalendarSync") .mockImplementation(async () => { await userMetadataService.updateUserMetadata({ userId, @@ -224,7 +225,7 @@ describe("SyncController", () => { ); expect(restartSpy).toHaveBeenCalledTimes(1); - expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); + expect(restartSpy).toHaveBeenCalledWith(userId); restartSpy.mockRestore(); }); @@ -328,8 +329,8 @@ describe("SyncController", () => { expect(watch).toBeDefined(); expect(watch).not.toBeNull(); - const handleGcalNotificationSpy = jest - .spyOn(syncService, "handleGcalNotification") + const handleGoogleWatchNotificationSpy = jest + .spyOn(googleWatchService, "handleGoogleWatchNotification") .mockRejectedValue(invalidGrant400Error); const pruneGoogleDataSpy = jest @@ -359,7 +360,7 @@ describe("SyncController", () => { expect(pruneGoogleDataSpy).toHaveBeenCalledWith(userId); expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); - handleGcalNotificationSpy.mockRestore(); + handleGoogleWatchNotificationSpy.mockRestore(); pruneGoogleDataSpy.mockRestore(); handleGoogleRevokedSpy.mockRestore(); }); @@ -376,8 +377,8 @@ describe("SyncController", () => { expect(watch).toBeDefined(); expect(watch).not.toBeNull(); - const handleGcalNotificationSpy = jest - .spyOn(syncService, "handleGcalNotification") + const handleGoogleWatchNotificationSpy = jest + .spyOn(googleWatchService, "handleGoogleWatchNotification") .mockRejectedValue(missingRefreshTokenError); const pruneGoogleDataSpy = jest @@ -407,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(syncService, "handleGcalNotification") + const handleGoogleWatchNotificationSpy = jest + .spyOn(googleWatchService, "handleGoogleWatchNotification") .mockRejectedValue(missingRefreshTokenError); const response = await syncDriver.handleGoogleNotification( @@ -430,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 03476a79e..1d107df65 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 { 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"; @@ -77,14 +79,12 @@ export class SyncController { userId: string, ): void => { // do not await this call - syncService - .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." }); }; @@ -132,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. - syncService - .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(); }; @@ -202,7 +200,8 @@ export class SyncController { ), }); - const response = await syncService.handleGcalNotification(syncPayload); + const response = + await googleWatchService.handleGoogleWatchNotification(syncPayload); res.promise(response); } catch (e) { @@ -259,7 +258,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 +272,7 @@ export class SyncController { }); }); // 5 minutes timeout - const result = await syncService.runMaintenance(); + const result = await googleWatchMaintenanceService.runMaintenance(); if (!res.headersSent) res.promise(result); } catch (e) { @@ -287,14 +286,16 @@ export class SyncController { const { force } = ImportGCalRequestSchema.parse(req.body); const isForce = force === true; - syncService - .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 62c21d622..6b0e051f2 100644 --- a/packages/backend/src/sync/controllers/sync.debug.controller.ts +++ b/packages/backend/src/sync/controllers/sync.debug.controller.ts @@ -1,14 +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 syncService from "../services/sync.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) => { @@ -29,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 syncService.importIncremental(userId); + const result = + await googleCalendarSyncService.importLatestGoogleCalendarChanges(userId); res.promise(result); }; @@ -54,9 +60,12 @@ class SyncDebugController { return; } - const result = await syncService.runMaintenanceByUser(userId, { - dry, - }); + const result = await googleWatchMaintenanceService.runMaintenanceByUser( + userId, + { + dry, + }, + ); res.promise(result); } catch (e) { @@ -90,7 +99,7 @@ class SyncDebugController { const calendarId = req.body.calendarId; const gcal = await getGcalClient(userId); - const watchResult = await syncService.startWatchingGcalEvents( + const watchResult = await googleWatchService.startEventWatch( userId, { gCalendarId: calendarId, @@ -113,7 +122,7 @@ class SyncDebugController { userId = req.session?.getUserId() as string; } - const stopResult = await syncService.stopWatches(userId); + const stopResult = await googleWatchService.stopWatches(userId); res.promise(stopResult); } catch (e) { const _e = e as BaseError; @@ -130,7 +139,7 @@ class SyncDebugController { const channelId = req.body.channelId; const resourceId = req.body.resourceId; - const stopResult = await syncService.stopWatch( + const stopResult = await googleWatchService.stopWatch( userId, channelId, resourceId, diff --git a/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.test.ts b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.test.ts new file mode 100644 index 000000000..b0ea7edd7 --- /dev/null +++ b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.test.ts @@ -0,0 +1,148 @@ +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 { googleCalendarSyncService } from "@backend/sync/services/google-calendar-sync/google-calendar-sync.service"; +import * as syncImportService from "@backend/sync/services/import/sync.import"; +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("googleCalendarSyncService", () => { + beforeAll(initSupertokens); + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterEach(() => jest.restoreAllMocks()); + afterAll(cleanupTestDb); + + describe("importLatestGoogleCalendarChanges", () => { + 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 googleCalendarSyncService.importLatestGoogleCalendarChanges(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 googleCalendarSyncService.importLatestGoogleCalendarChanges(userId); + + expect(importEndSpy).toHaveBeenCalledWith(userId, { + operation: "INCREMENTAL", + status: "COMPLETED", + }); + }); + }); + + 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 startWatching = googleWatchService.startGoogleWatches; + + jest.spyOn(syncImportService, "createSyncImport").mockResolvedValue({ + importAllEvents: jest.fn().mockImplementation(async () => { + callOrder.push("importFull"); + return { + nextSyncToken: "next-sync-token", + totalChanged: 0, + totalProcessed: 0, + totalSaved: 0, + }; + }), + } as unknown as Awaited< + ReturnType + >); + jest + .spyOn(googleWatchService, "startGoogleWatches") + .mockImplementation(async (...args) => { + callOrder.push("startWatching"); + return startWatching(...args); + }); + + await googleCalendarSyncService.initializeGoogleCalendarSync(userId); + + expect(callOrder).toEqual(["importFull", "startWatching"]); + }); + }); + + 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"); + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "COMPLETED" } }, + }); + + const stopSpy = jest.spyOn(userService, "stopGoogleCalendarSync"); + const startSpy = jest.spyOn( + googleCalendarSyncService, + "initializeGoogleCalendarSync", + ); + + await googleCalendarSyncService.startGoogleCalendarSyncIfNeeded(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`, + }); + }); + }); + + describe("repairGoogleCalendarSync", () => { + it("forces sync setup 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(googleCalendarSyncService, "initializeGoogleCalendarSync") + .mockResolvedValue({ eventsCount: 0, calendarsCount: 0 }); + + await googleCalendarSyncService.repairGoogleCalendarSync(userId); + + expect(stopSpy).toHaveBeenCalledWith(userId); + expect(startSpy).toHaveBeenCalledWith(userId); + }); + }); +}); diff --git a/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts new file mode 100644 index 000000000..d093404e5 --- /dev/null +++ b/packages/backend/src/sync/services/google-calendar-sync/google-calendar-sync.service.ts @@ -0,0 +1,301 @@ +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 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 { 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-calendar-sync.service"); + +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, + ); + + 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; + } +} + +async function importLatestGoogleCalendarChanges( + 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; + } +} + +async function runGoogleCalendarSyncSetup( + 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; + } + + 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 googleCalendarSyncService.initializeGoogleCalendarSync(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 { + activeFullSyncRestarts.delete(userId); + } +} + +async function initializeGoogleCalendarSync( + 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 importFull(gcal, gCalendarIds, user); + + if (isUsingGcalWebhookHttps()) { + await googleWatchService.startGoogleWatches( + 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, + }; +} + +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 388453aaf..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 { 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 syncService from "@backend/sync/services/sync.service"; +import { googleWatchService } from "@backend/sync/services/watch/google-watch.service"; import { getGCalEventsSyncPageToken, getSync, @@ -547,7 +547,7 @@ export class SyncImport { undefined, ); - await syncService.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/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/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 820ece952..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,18 +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 syncService from "@backend/sync/services/sync.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; @@ -74,7 +75,7 @@ export const pruneSync = async ( try { const results = await Promise.all( payload.map(({ _id, resourceId }) => - syncService.stopWatch( + googleWatchService.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 googleWatchService.refreshWatch( user, { ...syncPayload, @@ -144,7 +145,7 @@ export const refreshWatch = async ( }; } catch (e) { if (isFullSyncRequired(e as Error)) { - void syncService.restartGoogleCalendarSync(r.user, { force: true }); + void googleCalendarSyncService.repairGoogleCalendarSync(r.user); resynced = true; } else { logger.error( diff --git a/packages/backend/src/sync/services/watch/google-watch-maintenance.service.test.ts b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.test.ts new file mode 100644 index 000000000..ecf0e12c9 --- /dev/null +++ b/packages/backend/src/sync/services/watch/google-watch-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 { googleWatchMaintenanceService } from "@backend/sync/services/watch/google-watch-maintenance.service"; + +describe("googleWatchMaintenanceService", () => { + 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 googleWatchMaintenanceService.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/watch/google-watch-maintenance.service.ts b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts new file mode 100644 index 000000000..30526e999 --- /dev/null +++ b/packages/backend/src/sync/services/watch/google-watch-maintenance.service.ts @@ -0,0 +1,140 @@ +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/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:google-watch-maintenance.service"); + +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(() => + googleWatchMaintenanceService + .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; +} + +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, + }; + + 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 googleWatchMaintenanceService = { + runMaintenance, + runMaintenanceByUser, +}; diff --git a/packages/backend/src/sync/services/watch/google-watch.service.test.ts b/packages/backend/src/sync/services/watch/google-watch.service.test.ts new file mode 100644 index 000000000..d1909aa2e --- /dev/null +++ b/packages/backend/src/sync/services/watch/google-watch.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 { googleWatchService } from "@backend/sync/services/watch/google-watch.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("googleWatchService", () => { + 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 googleWatchService.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( + googleWatchService.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( + googleWatchService.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(googleWatchService, "cleanupStaleWatch") + .mockResolvedValue(false); + + await expect( + googleWatchService.handleGoogleWatchNotification({ + 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( + googleWatchService, + "startCalendarListWatch", + ); + const startEventWatchSpy = jest.spyOn( + googleWatchService, + "startEventWatch", + ); + + await expect( + googleWatchService.startGoogleWatches( + "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/watch/google-watch.service.ts b/packages/backend/src/sync/services/watch/google-watch.service.ts new file mode 100644 index 000000000..b91d5efd8 --- /dev/null +++ b/packages/backend/src/sync/services/watch/google-watch.service.ts @@ -0,0 +1,494 @@ +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 { 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 { getGcalClient } from "@backend/sync/services/google-calendar-sync/google.calendar.client"; +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:google-watch.service"); + +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, + })); +} + +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); + + 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 function cleanupStaleWatch({ + 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 googleWatchService.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; + } +} + +async function handleGoogleWatchNotification(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 googleWatchService.cleanupStaleWatch(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 googleWatchService.cleanupStaleWatch(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; +} + +async function refreshWatch( + userId: string, + payload: Params_WatchEvents, + gcal?: gCalendar, +) { + if (!gcal) gcal = await getGcalClient(userId); + + const watchExists = payload.channelId && payload.resourceId; + + if (watchExists) { + await googleWatchService.stopWatch( + userId, + payload.channelId, + payload.resourceId, + gcal, + ); + } + + const watchResult = await googleWatchService.startGoogleWatches( + userId, + [{ gCalendarId: payload.gCalendarId, quotaUser: payload.quotaUser }], + gcal, + ); + + return watchResult[0]; +} + +async function startCalendarListWatch( + 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 googleWatchService.stopWatch(user, channelId, resourceId, gcal); + + throw error; + }); + + return watch; + } catch (err) { + logger.error(`Error starting calendar watch for user: ${user}`, err); + + return { acknowledged: false }; + } +} + +async function startEventWatch( + 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 googleWatchService.stopWatch(user, channelId, resourceId, gcal); + + throw error; + }); + + return watch; + } catch (err) { + logger.error(`Error starting events watch for user: ${user}`, err); + + return { acknowledged: false }; + } +} + +async function startGoogleWatches( + 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 googleWatchService.startCalendarListWatch(userId, params, gcal); + } + + return googleWatchService.startEventWatch(userId, params, gcal); + }), + ).then((results) => results.filter((r) => r !== undefined)); +} + +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, + }); + + 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; + } +} + +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 }) => + googleWatchService + .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 googleWatchService = { + deleteWatchesByUser, + cleanupStaleWatch, + handleGoogleWatchNotification, + refreshWatch, + 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 29253c27f..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 syncService from "@backend/sync/services/sync.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(syncService, "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(syncService, "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 6022a850d..46984b408 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 { 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"; @@ -258,7 +259,7 @@ describe("UserService", () => { await priorityService.createDefaultPriorities(userId); await SyncDriver.createSync(storedUser!, true); - await syncService.startGoogleCalendarSync(userId); + await googleCalendarSyncService.initializeGoogleCalendarSync(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 googleCalendarSyncService.initializeGoogleCalendarSync(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(googleWatchService, "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(googleWatchService, "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(googleWatchService, "stopWatches"); + const deleteWatchesSpy = jest.spyOn( + googleWatchService, + "deleteWatchesByUser", + ); expect(user.google).toBeDefined(); - await syncService.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 4a1791c15..5894089fb 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 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 { @@ -198,7 +199,7 @@ class UserService { summary.events = events.deletedCount; if (gcalAccess) { - const watches = await syncService.stopWatches( + const watches = await googleWatchService.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 googleWatchService.deleteWatchesByUser(userId); } else { - await syncService.stopWatches(userId); + await googleWatchService.stopWatches(userId); } - await syncService.deleteByIntegration("google", userId); + await syncRecords.deleteByIntegration("google", userId); }; handleLogoutCleanup = async ( @@ -293,25 +294,28 @@ class UserService { } if (options.isLastActiveSession) { - await syncService.stopWatches(userId); + await googleWatchService.stopWatches(userId); } }; - 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" }, ); @@ -319,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 }); 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 } 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..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 syncService from "@backend/sync/services/sync.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 syncService + await googleWatchService .stopWatch(syncDoc.user, s.channelId, s.resourceId, gcal, quotaUser) .catch(logger.error);