diff --git a/AGENTS.md b/AGENTS.md index acae041f7..f134b4ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,6 +231,7 @@ packages/core/src/ - Use `is` prefix for boolean variables. For example, `isLoading`, `isError`, `isSuccess` - Do not use barrel (`index.ts`) files. Use named exports instead. +- When creating constants, use uppercase and underscores. Example: `SIGNIN_INCREMENTAL` ## Branch Naming & Commit Message Conventions diff --git a/docs/api-documentation.md b/docs/api-documentation.md index fb2f2bada..e1a8e3a7a 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -137,8 +137,8 @@ Current metadata shape used by sync/auth flows: ```ts { sync?: { - importGCal?: "importing" | "errored" | "completed" | "restart" | null; - incrementalGCalSync?: "importing" | "errored" | "completed" | "restart" | null; + importGCal?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null; + incrementalGCalSync?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null; }; google?: { hasRefreshToken?: boolean; diff --git a/docs/google-sync-and-websocket-flow.md b/docs/google-sync-and-websocket-flow.md index b446feb2f..c6c553125 100644 --- a/docs/google-sync-and-websocket-flow.md +++ b/docs/google-sync-and-websocket-flow.md @@ -73,7 +73,7 @@ Important error handling behavior: - if no watch exists, backend logs and returns `410` without pruning (notification ignored) - missing sync token: - backend attempts forced resync in background - - resync is skipped if metadata already shows `sync.importGCal === "importing"` + - resync is skipped if metadata already shows `sync.importGCal === "IMPORTING"` - response is `204` either way - invalid/revoked Google token (`invalid_grant`): - backend prunes Google data, emits `GOOGLE_REVOKED`, returns revoked payload @@ -125,8 +125,25 @@ Revocation and reconnect are handled across auth, sync, websocket, and repositor 1. Backend detects missing/invalid Google refresh token (middleware, sync, or Google API error handling). 2. Backend prunes Google-origin data and emits `GOOGLE_REVOKED`. 3. Web app marks Google as revoked in session memory and temporarily switches to local repository behavior. -4. OAuth connect while a session exists triggers reconnect logic (`reconnectGoogleForSession`) instead of normal signup/signin. -5. Reconnect updates Google credentials, marks metadata sync flags as `"restart"`, and restarts sync in background. +4. User initiates re-consent via OAuth flow. +5. Backend auth handler (`handleGoogleAuth`) determines auth mode server-side using: + - User existence (via `findCompassUserBy`) + - Refresh token presence (`user.google.gRefreshToken`) + - Sync health (`canDoIncrementalSync`) +6. If user exists but refresh token is missing or sync is unhealthy → `RECONNECT_REPAIR` path via `repairGoogleConnection()`. +7. Reconnect updates Google credentials, marks metadata sync flags as `"restart"`, and restarts sync in background. + +### Auth Mode Classification + +The backend determines auth mode based on server-side state, and the client only launches OAuth plus reacts to metadata/socket updates: + +| Condition | Auth Mode | Handler | +| ----------------------------------------------------- | -------------------- | -------------------------- | +| No linked Compass user | `SIGNUP` | `googleSignup()` | +| User exists + missing refresh token OR unhealthy sync | `RECONNECT_REPAIR` | `repairGoogleConnection()` | +| User exists + valid refresh token + healthy sync | `SIGNIN_INCREMENTAL` | `googleSignin()` | + +Note: Frontend reconnect intent is no longer used for routing. The server is the source of truth for auth mode selection. Primary files: @@ -144,8 +161,8 @@ Primary files: ```ts { sync?: { - importGCal?: "importing" | "errored" | "completed" | "restart" | null; - incrementalGCalSync?: "importing" | "errored" | "completed" | "restart" | null; + importGCal?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null; + incrementalGCalSync?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null; }; google?: { hasRefreshToken?: boolean; @@ -164,7 +181,7 @@ Google import progress is also realtime: 1. backend starts import 2. websocket emits `IMPORT_GCAL_START` -3. client marks import pending +3. client waits for metadata/socket updates from the backend import flow 4. backend completes import and emits `IMPORT_GCAL_END` 5. client stores import results and triggers a refetch diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index aee9a833b..310756ae1 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -213,8 +213,9 @@ export const fillTitleAndSaveWithKeyboard = async ( await expect(titleInput).toBeVisible({ timeout: FORM_TIMEOUT }); await titleInput.fill(title); // EventForm saves on Cmd+Enter (Mac) or Ctrl+Enter (Linux/Windows) - // ControlOrMeta maps to the platform-appropriate modifier - await page.keyboard.press("ControlOrMeta+Enter"); + // ControlOrMeta maps to the platform-appropriate modifier. + // Use locator.press() so focus is on the input when the key is sent (fixes CI). + await titleInput.press("ControlOrMeta+Enter"); // Wait for form to close, confirming the save completed await titleInput.waitFor({ state: "hidden", timeout: FORM_TIMEOUT }); }; diff --git a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts index 49856fb41..4c4ad7560 100644 --- a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts +++ b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts @@ -8,12 +8,12 @@ export type ParsedReconnectGoogleParams = { }; export function parseReconnectGoogleParams( - sessionUserId: string, + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): ParsedReconnectGoogleParams { const cUserId = zObjectId - .parse(sessionUserId, { error: () => "Invalid credentials" }) + .parse(compassUserId, { error: () => "Invalid credentials" }) .toString(); StringV4Schema.parse(gUser.sub, { error: () => "Invalid Google user ID" }); const refreshToken = StringV4Schema.parse(oAuthTokens.refresh_token, { diff --git a/packages/backend/src/auth/services/compass.auth.service.test.ts b/packages/backend/src/auth/services/compass.auth.service.test.ts index f08699698..7e3999fc2 100644 --- a/packages/backend/src/auth/services/compass.auth.service.test.ts +++ b/packages/backend/src/auth/services/compass.auth.service.test.ts @@ -1,4 +1,4 @@ -import { type Credentials } from "google-auth-library"; +import type { Credentials } from "google-auth-library"; import { faker } from "@faker-js/faker"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { @@ -18,10 +18,10 @@ describe("CompassAuthService", () => { beforeEach(cleanupCollections); afterAll(cleanupTestDb); - describe("reconnectGoogleForSession", () => { - it("relinks Google to the current Compass user and schedules a full reimport", async () => { + describe("repairGoogleConnection", () => { + it("relinks Google to the Compass user and schedules a full reimport", async () => { const user = await UserDriver.createUser(); - const sessionUserId = user._id.toString(); + const compassUserId = user._id.toString(); const gUser = UserDriver.generateGoogleUser({ sub: faker.string.uuid(), picture: faker.image.url(), @@ -34,35 +34,35 @@ describe("CompassAuthService", () => { .spyOn(userService, "restartGoogleCalendarSync") .mockResolvedValue(); - await userService.pruneGoogleData(sessionUserId); + await userService.pruneGoogleData(compassUserId); - const result = await compassAuthService.reconnectGoogleForSession( - sessionUserId, + const result = await compassAuthService.repairGoogleConnection( + compassUserId, gUser, oAuthTokens, ); const updatedUser = await mongoService.user.findOne({ _id: user._id }); const metadata = - await userMetadataService.fetchUserMetadata(sessionUserId); + await userMetadataService.fetchUserMetadata(compassUserId); - expect(result).toEqual({ cUserId: sessionUserId }); - expect(updatedUser?._id.toString()).toBe(sessionUserId); + expect(result).toEqual({ cUserId: compassUserId }); + expect(updatedUser?._id.toString()).toBe(compassUserId); expect(updatedUser?.google?.googleId).toBe(gUser.sub); expect(updatedUser?.google?.picture).toBe(gUser.picture); expect(updatedUser?.google?.gRefreshToken).toBe( oAuthTokens.refresh_token, ); - expect(metadata.sync?.importGCal).toBe("restart"); - expect(metadata.sync?.incrementalGCalSync).toBe("restart"); - expect(restartSpy).toHaveBeenCalledWith(sessionUserId); + expect(metadata.sync?.importGCal).toBe("RESTART"); + expect(metadata.sync?.incrementalGCalSync).toBe("RESTART"); + expect(restartSpy).toHaveBeenCalledWith(compassUserId); restartSpy.mockRestore(); }); it("returns after persisting reconnect state even if the background sync fails", async () => { const user = await UserDriver.createUser(); - const sessionUserId = user._id.toString(); + const compassUserId = user._id.toString(); const gUser = UserDriver.generateGoogleUser({ sub: faker.string.uuid(), picture: faker.image.url(), @@ -76,19 +76,19 @@ describe("CompassAuthService", () => { .spyOn(userService, "restartGoogleCalendarSync") .mockRejectedValue(restartError); - await userService.pruneGoogleData(sessionUserId); + await userService.pruneGoogleData(compassUserId); await expect( - compassAuthService.reconnectGoogleForSession( - sessionUserId, + compassAuthService.repairGoogleConnection( + compassUserId, gUser, oAuthTokens, ), - ).resolves.toEqual({ cUserId: sessionUserId }); + ).resolves.toEqual({ cUserId: compassUserId }); await Promise.resolve(); - expect(restartSpy).toHaveBeenCalledWith(sessionUserId); + expect(restartSpy).toHaveBeenCalledWith(compassUserId); restartSpy.mockRestore(); }); diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index 67c3e351e..11c537186 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -8,14 +8,10 @@ import { parseReconnectGoogleParams } from "@backend/auth/schemas/reconnect-goog import GoogleAuthService from "@backend/auth/services/google/google.auth.service"; import { ENV } from "@backend/common/constants/env.constants"; import { isMissingUserTagId } from "@backend/common/constants/env.util"; -import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.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 { getSync } from "@backend/sync/util/sync.queries"; -import { canDoIncrementalSync } from "@backend/sync/util/sync.util"; -import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; import userService from "@backend/user/services/user.service"; @@ -31,28 +27,6 @@ class CompassAuthService { }); }; - determineAuthMethod = async (gUserId: string) => { - const user = await findCompassUserBy("google.googleId", gUserId); - - if (!user) { - return { authMethod: "signup", user: null }; - } - const userId = user._id.toString(); - - const sync = await getSync({ userId }); - if (!sync) { - throw error( - SyncError.NoSyncRecordForUser, - "Did not verify sync record for user", - ); - } - - const canLogin = canDoIncrementalSync(sync); - const authMethod = user && canLogin ? "login" : "signup"; - - return { authMethod, user }; - }; - createSessionForUser = async (cUserId: string) => { const userId = cUserId; const sUserId = supertokens.convertToRecipeUserId(cUserId); @@ -100,7 +74,7 @@ class CompassAuthService { userId, data: { skipOnboarding: false, - sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, }, }); @@ -126,8 +100,17 @@ class CompassAuthService { return user; } - async reconnectGoogleForSession( - sessionUserId: string, + /** + * Repairs a user's Google connection after revocation or disconnection. + * This method is called when the user has an existing Compass account but + * their refresh token is missing or their sync state is unhealthy. + * + * @param compassUserId - The Compass user ID (not session-based) + * @param gUser - Google user info from OAuth + * @param oAuthTokens - Fresh OAuth tokens from re-consent + */ + async repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ) { @@ -135,7 +118,7 @@ class CompassAuthService { cUserId, gUser: validatedGUser, refreshToken, - } = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens); + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); await userService.reconnectGoogleCredentials( cUserId, @@ -146,7 +129,7 @@ class CompassAuthService { await userMetadataService.updateUserMetadata({ userId: cUserId, data: { - sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, }, }); @@ -201,7 +184,7 @@ class CompassAuthService { // mark in metadata to restart full import await userMetadataService.updateUserMetadata({ userId: cUserId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); this.restartGoogleCalendarSyncInBackground(cUserId); 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 40dfe39cd..51ea843c1 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,10 +1,23 @@ -import { type Credentials, type TokenPayload } from "google-auth-library"; +import { Credentials, TokenPayload } from "google-auth-library"; +import { ObjectId } from "mongodb"; import { faker } from "@faker-js/faker"; import { - type GoogleSignInSuccess, - type GoogleSignInSuccessAuthService, + GoogleSignInSuccess, + GoogleSignInSuccessAuthService, handleGoogleAuth, } from "@backend/auth/services/google/google.auth.success.service"; +import * as syncQueries from "@backend/sync/util/sync.queries"; +import * as syncUtil from "@backend/sync/util/sync.util"; +import * as userQueries from "@backend/user/queries/user.queries"; + +// Mock the dependencies +jest.mock("@backend/user/queries/user.queries"); +jest.mock("@backend/sync/util/sync.queries"); +jest.mock("@backend/sync/util/sync.util"); + +const mockFindCompassUserBy = userQueries.findCompassUserBy as jest.Mock; +const mockGetSync = syncQueries.getSync as jest.Mock; +const mockCanDoIncrementalSync = syncUtil.canDoIncrementalSync as jest.Mock; function makeProviderUser(overrides?: Partial): TokenPayload { return { @@ -26,51 +39,43 @@ function makeOAuthTokens(): Pick< } function createMockAuthService(): GoogleSignInSuccessAuthService & { - reconnectGoogleForSession: jest.Mock; + repairGoogleConnection: jest.Mock; googleSignup: jest.Mock; googleSignin: jest.Mock; } { return { - reconnectGoogleForSession: jest + repairGoogleConnection: jest .fn() - .mockResolvedValue({ cUserId: "reconnect-id" }), + .mockResolvedValue({ cUserId: "repair-id" }), googleSignup: jest.fn().mockResolvedValue({ cUserId: "signup-id" }), googleSignin: jest.fn().mockResolvedValue({ cUserId: "signin-id" }), }; } -describe("handleGoogleSignInSuccess", () => { - describe("reconnect path", () => { - it("calls reconnectGoogleForSession when sessionUserId is set", async () => { - const authService = createMockAuthService(); - const providerUser = makeProviderUser(); - const oAuthTokens = makeOAuthTokens(); - const sessionUserId = faker.database.mongodbObjectId(); - - const success: GoogleSignInSuccess = { - providerUser, - oAuthTokens, - createdNewRecipeUser: false, - recipeUserId: sessionUserId, - loginMethodsLength: 1, - sessionUserId, - }; - - await handleGoogleAuth(success, authService); +function makeCompassUser(overrides?: { + hasRefreshToken?: boolean; + googleId?: string; +}) { + const _id = new ObjectId(); + return { + _id, + google: { + googleId: overrides?.googleId ?? faker.string.uuid(), + gRefreshToken: + overrides?.hasRefreshToken !== false ? faker.string.uuid() : null, + }, + }; +} - expect(authService.reconnectGoogleForSession).toHaveBeenCalledTimes(1); - expect(authService.reconnectGoogleForSession).toHaveBeenCalledWith( - sessionUserId, - providerUser, - oAuthTokens, - ); - expect(authService.googleSignup).not.toHaveBeenCalled(); - expect(authService.googleSignin).not.toHaveBeenCalled(); - }); +describe("handleGoogleAuth", () => { + beforeEach(() => { + jest.clearAllMocks(); }); - describe("sign up path", () => { - it("calls googleSignup when new user with single login method", async () => { + describe("signup path", () => { + it("calls googleSignup when no existing Compass user found", async () => { + mockFindCompassUserBy.mockResolvedValue(null); + const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); @@ -82,7 +87,6 @@ describe("handleGoogleSignInSuccess", () => { createdNewRecipeUser: true, recipeUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -93,11 +97,13 @@ describe("handleGoogleSignInSuccess", () => { oAuthTokens.refresh_token, recipeUserId, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignin).not.toHaveBeenCalled(); }); it("throws when refresh_token is missing for new user", async () => { + mockFindCompassUserBy.mockResolvedValue(null); + const authService = createMockAuthService(); const success: GoogleSignInSuccess = { providerUser: makeProviderUser(), @@ -105,7 +111,6 @@ describe("handleGoogleSignInSuccess", () => { createdNewRecipeUser: true, recipeUserId: faker.database.mongodbObjectId(), loginMethodsLength: 1, - sessionUserId: null, }; await expect(handleGoogleAuth(success, authService)).rejects.toThrow( @@ -116,50 +121,224 @@ describe("handleGoogleSignInSuccess", () => { }); }); - describe("sign in path", () => { - it("calls googleSignin when returning user", async () => { + describe("RECONNECT_REPAIR path", () => { + it("calls repairGoogleConnection when user exists but refresh token is missing", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: false }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(true); + const authService = createMockAuthService(); - const providerUser = makeProviderUser(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); const oAuthTokens = makeOAuthTokens(); const success: GoogleSignInSuccess = { providerUser, oAuthTokens, createdNewRecipeUser: false, - recipeUserId: faker.database.mongodbObjectId(), + recipeUserId: compassUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); - expect(authService.googleSignin).toHaveBeenCalledTimes(1); - expect(authService.googleSignin).toHaveBeenCalledWith( + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.repairGoogleConnection).toHaveBeenCalledWith( + compassUserId, providerUser, oAuthTokens, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); }); - it("calls googleSignin when createdNewRecipeUser is true but loginMethodsLength > 1", async () => { + it("calls repairGoogleConnection when user exists but sync is unhealthy", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: true }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(false); // Unhealthy sync + const authService = createMockAuthService(); - const providerUser = makeProviderUser(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); const oAuthTokens = makeOAuthTokens(); const success: GoogleSignInSuccess = { providerUser, oAuthTokens, - createdNewRecipeUser: true, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + }; + + await handleGoogleAuth(success, authService); + + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.repairGoogleConnection).toHaveBeenCalledWith( + compassUserId, + providerUser, + oAuthTokens, + ); + expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); + }); + + it("calls repairGoogleConnection when both refresh token is missing and sync is unhealthy", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: false }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(false); + + const authService = createMockAuthService(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + }; + + await handleGoogleAuth(success, authService); + + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); + }); + + it("calls repairGoogleConnection when no sync record exists", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: true }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue(null); // No sync record + + const authService = createMockAuthService(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + }; + + await handleGoogleAuth(success, authService); + + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); + }); + }); + + describe("SIGNIN_INCREMENTAL path", () => { + it("calls googleSignin when user exists with valid refresh token and healthy sync", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: true }); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ + google: { events: [{ nextSyncToken: "token" }] }, + }); + mockCanDoIncrementalSync.mockReturnValue(true); + + const authService = createMockAuthService(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, recipeUserId: faker.database.mongodbObjectId(), - loginMethodsLength: 2, - sessionUserId: null, + loginMethodsLength: 1, }; await handleGoogleAuth(success, authService); expect(authService.googleSignin).toHaveBeenCalledTimes(1); + expect(authService.googleSignin).toHaveBeenCalledWith( + providerUser, + oAuthTokens, + ); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignup).not.toHaveBeenCalled(); }); }); + + describe("auth decision logging", () => { + it("determines correct auth mode for each scenario", async () => { + // This test verifies that determineAuthMode returns the expected values + // by checking which handler gets called + + const authService = createMockAuthService(); + + // Scenario 1: No user → SIGNUP + mockFindCompassUserBy.mockResolvedValue(null); + await handleGoogleAuth( + { + providerUser: makeProviderUser(), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: true, + recipeUserId: faker.database.mongodbObjectId(), + loginMethodsLength: 1, + }, + authService, + ); + expect(authService.googleSignup).toHaveBeenCalled(); + + jest.clearAllMocks(); + + // Scenario 2: User exists but no refresh token → RECONNECT_REPAIR + const userNoToken = makeCompassUser({ hasRefreshToken: false }); + mockFindCompassUserBy.mockResolvedValue(userNoToken); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(true); + await handleGoogleAuth( + { + providerUser: makeProviderUser({ sub: userNoToken.google.googleId }), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: false, + recipeUserId: userNoToken._id.toString(), + loginMethodsLength: 1, + }, + authService, + ); + expect(authService.repairGoogleConnection).toHaveBeenCalled(); + + jest.clearAllMocks(); + + // Scenario 3: User exists with token and healthy sync → SIGNIN_INCREMENTAL + const healthyUser = makeCompassUser({ hasRefreshToken: true }); + mockFindCompassUserBy.mockResolvedValue(healthyUser); + mockGetSync.mockResolvedValue({ + google: { events: [{ nextSyncToken: "token" }] }, + }); + mockCanDoIncrementalSync.mockReturnValue(true); + await handleGoogleAuth( + { + providerUser: makeProviderUser({ sub: healthyUser.google.googleId }), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: false, + recipeUserId: healthyUser._id.toString(), + loginMethodsLength: 1, + }, + authService, + ); + expect(authService.googleSignin).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.ts b/packages/backend/src/auth/services/google/google.auth.success.service.ts index 0078a9479..3431d133c 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.ts @@ -1,4 +1,10 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; +import { Logger } from "@core/logger/winston.logger"; +import { getSync } from "@backend/sync/util/sync.queries"; +import { canDoIncrementalSync } from "@backend/sync/util/sync.util"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +const logger = Logger("app:google.auth.success"); export type GoogleSignInSuccess = { providerUser: TokenPayload; @@ -6,12 +12,27 @@ export type GoogleSignInSuccess = { createdNewRecipeUser: boolean; recipeUserId: string; loginMethodsLength: number; - sessionUserId: string | null; +}; + +/** + * Auth modes for Google sign-in flow: + * - SIGNUP: New user, no linked Compass account + * - SIGNIN_INCREMENTAL: Existing user with valid refresh token and healthy sync + * - RECONNECT_REPAIR: Existing user needing repair (missing refresh token or unhealthy sync) + */ +export type AuthMode = "SIGNUP" | "SIGNIN_INCREMENTAL" | "RECONNECT_REPAIR"; + +export type AuthDecision = { + authMode: AuthMode; + compassUserId: string | null; + hasStoredRefreshToken: boolean; + hasHealthySync: boolean; + createdNewRecipeUser: boolean; }; export interface GoogleSignInSuccessAuthService { - reconnectGoogleForSession( - sessionUserId: string, + repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): Promise<{ cUserId: string }>; @@ -26,6 +47,58 @@ export interface GoogleSignInSuccessAuthService { ): Promise<{ cUserId: string }>; } +/** + * Determines the auth mode based on server-side state. + * + * Decision logic: + * - If no linked Compass user exists → SIGNUP + * - If user exists but refresh token is missing OR sync is unhealthy → RECONNECT_REPAIR + * - Otherwise → SIGNIN_INCREMENTAL + */ +async function determineAuthMode( + googleUserId: string, + createdNewRecipeUser: boolean, +): Promise { + // Look up existing user by Google ID + const user = await findCompassUserBy("google.googleId", googleUserId); + + if (!user) { + return { + authMode: "SIGNUP", + compassUserId: null, + hasStoredRefreshToken: false, + hasHealthySync: false, + createdNewRecipeUser, + }; + } + + const compassUserId = user._id.toString(); + const hasStoredRefreshToken = !!user.google?.gRefreshToken; + + // Check sync health + const sync = await getSync({ userId: compassUserId }); + const hasHealthySync = sync ? !!canDoIncrementalSync(sync) : false; + + // If missing refresh token OR unhealthy sync → needs repair + if (!hasStoredRefreshToken || !hasHealthySync) { + return { + authMode: "RECONNECT_REPAIR", + compassUserId, + hasStoredRefreshToken, + hasHealthySync, + createdNewRecipeUser, + }; + } + + return { + authMode: "SIGNIN_INCREMENTAL", + compassUserId, + hasStoredRefreshToken, + hasHealthySync, + createdNewRecipeUser, + }; +} + export async function handleGoogleAuth( success: GoogleSignInSuccess, authService: GoogleSignInSuccessAuthService, @@ -36,28 +109,51 @@ export async function handleGoogleAuth( createdNewRecipeUser, recipeUserId, loginMethodsLength, - sessionUserId, } = success; - if (sessionUserId !== null) { - await authService.reconnectGoogleForSession( - sessionUserId, - providerUser, - oAuthTokens, - ); - return; + const googleUserId = providerUser.sub; + if (!googleUserId) { + throw new Error("Google user ID (sub) is required"); } - const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + // Determine auth mode based on server-side state + const decision = await determineAuthMode(googleUserId, createdNewRecipeUser); - if (isNewUser) { - const refreshToken = oAuthTokens.refresh_token; - if (!refreshToken) { - throw new Error("Refresh token expected for new user sign-up"); + 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 authService.googleSignup(providerUser, refreshToken, recipeUserId); + return; + } + + case "RECONNECT_REPAIR": { + // User exists but needs repair (missing refresh token or unhealthy sync) + await authService.repairGoogleConnection( + decision.compassUserId!, + providerUser, + oAuthTokens, + ); + return; } - await authService.googleSignup(providerUser, refreshToken, recipeUserId); - return; - } - await authService.googleSignin(providerUser, oAuthTokens); + case "SIGNIN_INCREMENTAL": { + // Healthy returning user - attempt incremental sync + await authService.googleSignin(providerUser, oAuthTokens); + return; + } + } } diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index d27634602..3e5a37465 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -20,7 +20,6 @@ import { type CreateGoogleSignInResponse, type ThirdPartySignInUpInput, createGoogleSignInSuccess, - getGoogleAuthIntent, } from "@backend/common/middleware/supertokens.middleware.util"; import mongoService from "@backend/common/services/mongo.service"; import syncService from "@backend/sync/services/sync.service"; @@ -129,13 +128,8 @@ export const initSupertokens = () => { const response = await originalImplementation.signInUpPOST(input); - const body = (await input.options.req.getJSONBody()) as { - googleAuthIntent?: unknown; - }; const success = createGoogleSignInSuccess( response as CreateGoogleSignInResponse, - getGoogleAuthIntent(body?.googleAuthIntent), - input.session?.getUserId() ?? null, ); if (success) { @@ -175,7 +169,7 @@ export const initSupertokens = () => { await userMetadataService.updateUserMetadata({ userId: userId.toString(), - data: { sync: { incrementalGCalSync: "restart" } }, + data: { sync: { incrementalGCalSync: "RESTART" } }, }); if (lastActiveSession) { diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts index fdc968ef2..ef20bd5e9 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts @@ -1,61 +1,8 @@ import { type TokenPayload } from "google-auth-library"; import { faker } from "@faker-js/faker"; -import { - createGoogleSignInSuccess, - resolveGoogleSessionUserId, -} from "@backend/common/middleware/supertokens.middleware.util"; +import { createGoogleSignInSuccess } from "@backend/common/middleware/supertokens.middleware.util"; describe("supertokens.middleware.util", () => { - describe("resolveGoogleSessionUserId", () => { - it("prefers the current session when one exists", () => { - const sessionUserId = faker.database.mongodbObjectId(); - const recipeUserId = faker.database.mongodbObjectId(); - - expect( - resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent: "reconnect", - createdNewRecipeUser: false, - recipeUserId, - }), - ).toBe(sessionUserId); - }); - - it("uses the recipe user id for reconnects without a session", () => { - const recipeUserId = faker.database.mongodbObjectId(); - - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - googleAuthIntent: "reconnect", - createdNewRecipeUser: false, - recipeUserId, - }), - ).toBe(recipeUserId); - }); - - it("keeps normal returning users on the sign-in path without reconnect intent", () => { - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - createdNewRecipeUser: false, - recipeUserId: faker.database.mongodbObjectId(), - }), - ).toBeNull(); - }); - - it("does not force reconnect behavior for new users", () => { - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - googleAuthIntent: "reconnect", - createdNewRecipeUser: true, - recipeUserId: faker.database.mongodbObjectId(), - }), - ).toBeNull(); - }); - }); - describe("createGoogleSignInSuccess", () => { it("returns null for non-OK responses", () => { expect( @@ -67,33 +14,28 @@ describe("supertokens.middleware.util", () => { it("embeds reconnect fallback user id into the auth success payload", () => { const recipeUserId = faker.database.mongodbObjectId(); - const success = createGoogleSignInSuccess( - { - status: "OK", - rawUserInfoFromProvider: { - fromIdTokenPayload: { - sub: faker.string.uuid(), - email: faker.internet.email(), - } as TokenPayload, - }, - oAuthTokens: { - refresh_token: faker.string.uuid(), - access_token: faker.internet.jwt(), - }, - createdNewRecipeUser: false, - user: { - id: recipeUserId, - loginMethods: [{}], - }, - } as Parameters[0], - "reconnect", - null, - ); + const success = createGoogleSignInSuccess({ + status: "OK", + rawUserInfoFromProvider: { + fromIdTokenPayload: { + sub: faker.string.uuid(), + email: faker.internet.email(), + } as TokenPayload, + }, + oAuthTokens: { + refresh_token: faker.string.uuid(), + access_token: faker.internet.jwt(), + }, + createdNewRecipeUser: false, + user: { + id: recipeUserId, + loginMethods: [{}], + }, + } as Parameters[0]); expect(success).toMatchObject({ createdNewRecipeUser: false, recipeUserId, - sessionUserId: recipeUserId, loginMethodsLength: 1, }); }); diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index beb365519..7e08ae64e 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -1,6 +1,5 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; import type { APIInterface } from "supertokens-node/recipe/thirdparty/types"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import type { GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.success.service"; type ThirdPartySignInUpPost = NonNullable; @@ -20,42 +19,8 @@ export type CreateGoogleSignInResponse = | { status: Exclude } | GoogleThirdPartySignInUpSuccess; -export function getGoogleAuthIntent( - value: unknown, -): GoogleAuthIntent | undefined { - if (value === "connect" || value === "reconnect") { - return value; - } - - return undefined; -} - -export function resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser, - recipeUserId, -}: { - sessionUserId: string | null; - googleAuthIntent?: GoogleAuthIntent; - createdNewRecipeUser: boolean; - recipeUserId: string; -}): string | null { - if (sessionUserId) { - return sessionUserId; - } - - if (googleAuthIntent === "reconnect" && !createdNewRecipeUser) { - return recipeUserId; - } - - return null; -} - export function createGoogleSignInSuccess( response: CreateGoogleSignInResponse, - googleAuthIntent?: GoogleAuthIntent, - sessionUserId: string | null = null, ): GoogleSignInSuccess | null { if (response.status !== "OK") return null; @@ -65,11 +30,5 @@ export function createGoogleSignInSuccess( createdNewRecipeUser: response.createdNewRecipeUser, recipeUserId: response.user.id, loginMethodsLength: response.user.loginMethods.length, - sessionUserId: resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser: response.createdNewRecipeUser, - recipeUserId: response.user.id, - }), }; } diff --git a/packages/backend/src/servers/websocket/websocket.server.test.ts b/packages/backend/src/servers/websocket/websocket.server.test.ts index 2b060a43d..9d5afeabb 100644 --- a/packages/backend/src/servers/websocket/websocket.server.test.ts +++ b/packages/backend/src/servers/websocket/websocket.server.test.ts @@ -399,7 +399,7 @@ describe("WebSocket Server", () => { google: { hasRefreshToken: false, connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }, }, ]); diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index a5a0db450..c6303129c 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -47,7 +47,7 @@ describe("SyncController", () => { const importTimeoutMs = 7_000; interface ImportSummary { - status: "completed"; + status: "COMPLETED"; eventsCount: number; calendarsCount: number; } @@ -57,7 +57,7 @@ describe("SyncController", () => { ): ImportSummary { expect(result).toEqual( expect.objectContaining({ - status: "completed", + status: "COMPLETED", eventsCount: expect.any(Number) as number, calendarsCount: expect.any(Number) as number, }), @@ -252,7 +252,7 @@ describe("SyncController", () => { .mockImplementation(async () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "importing" } }, + data: { sync: { importGCal: "IMPORTING" } }, }); }); @@ -628,11 +628,11 @@ describe("SyncController", () => { const { sync } = await userMetadataService.fetchUserMetadata(userId); - expect(sync?.importGCal).toEqual("completed"); + expect(sync?.importGCal).toEqual("COMPLETED"); await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); await waitUntilImportGCalEnd(websocketClient, () => @@ -657,7 +657,7 @@ describe("SyncController", () => { const { sync } = await userMetadataService.fetchUserMetadata(userId); - expect(sync?.importGCal).toEqual("completed"); + expect(sync?.importGCal).toEqual("COMPLETED"); const result = await waitUntilImportGCalEnd( websocketClient, @@ -684,7 +684,7 @@ describe("SyncController", () => { const { sync } = await userMetadataService.fetchUserMetadata(userId); - expect(sync?.importGCal).toEqual("completed"); + expect(sync?.importGCal).toEqual("COMPLETED"); const getGCalEventsSyncPageTokenSpy = jest .spyOn(syncQueries, "getGCalEventsSyncPageToken") @@ -699,7 +699,7 @@ describe("SyncController", () => { ); expect(failReason).toEqual({ - status: "ignored", + status: "IGNORED", message: `User ${userId} gcal import is in progress or completed, ignoring this request`, }); @@ -735,7 +735,7 @@ describe("SyncController", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "importing" } }, + data: { sync: { importGCal: "IMPORTING" } }, }); const failReason = await waitUntilImportGCalEnd( @@ -745,7 +745,7 @@ describe("SyncController", () => { ); expect(failReason).toEqual({ - status: "ignored", + status: "IGNORED", message: `User ${userId} gcal import is in progress or completed, ignoring this request`, }); @@ -781,7 +781,7 @@ describe("SyncController", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); const result = await waitUntilImportGCalEnd( @@ -828,7 +828,7 @@ describe("SyncController", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "errored" } }, + data: { sync: { importGCal: "ERRORED" } }, }); const result = await waitUntilImportGCalEnd( diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 81684e931..bdefb8072 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -120,7 +120,7 @@ export class SyncController { ); const importStatus = metadata.sync?.importGCal; - if (importStatus === "importing" || importStatus === "restart") { + if (importStatus === "IMPORTING" || importStatus === "RESTART") { logger.info( `Skipped Google sync recovery because full import is already active for user: ${userId}`, ); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index 2f037efe8..c61b46899 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -281,7 +281,7 @@ class SyncService { if (!proceed) { webSocketServer.handleImportGCalEnd(userId, { - status: "ignored", + status: "IGNORED", message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, }); @@ -290,7 +290,7 @@ class SyncService { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { incrementalGCalSync: "importing" } }, + data: { sync: { incrementalGCalSync: "IMPORTING" } }, }); const syncImport = gcal @@ -301,11 +301,11 @@ class SyncService { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { incrementalGCalSync: "completed" } }, + data: { sync: { incrementalGCalSync: "COMPLETED" } }, }); webSocketServer.handleImportGCalEnd(userId, { - status: "completed", + status: "COMPLETED", }); webSocketServer.handleBackgroundCalendarChange(userId); @@ -313,7 +313,7 @@ class SyncService { } catch (error) { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { incrementalGCalSync: "errored" } }, + data: { sync: { incrementalGCalSync: "ERRORED" } }, }); logger.error( @@ -322,7 +322,7 @@ class SyncService { ); webSocketServer.handleImportGCalEnd(userId, { - status: "errored", + status: "ERRORED", message: `Incremental Google Calendar sync failed for user: ${userId}`, }); diff --git a/packages/backend/src/sync/util/sync.util.ts b/packages/backend/src/sync/util/sync.util.ts index 9305295d4..7f016549f 100644 --- a/packages/backend/src/sync/util/sync.util.ts +++ b/packages/backend/src/sync/util/sync.util.ts @@ -42,11 +42,32 @@ export const hasGoogleHeaders = (headers: object) => { return hasHeaders; }; +/** + * Determines if incremental sync can be performed for a sync record. + * + * Returns true only if: + * - Sync record exists + * - Google events array exists and is not empty + * - Every calendar event has a non-null nextSyncToken + * + * Returns false if: + * - Sync record is missing Google events data + * - Any calendar event is missing a sync token + * - Events array is empty (no calendars to sync) + * + * This is used to determine if a user needs a full restart sync + * (RECONNECT_REPAIR) vs incremental sync (SIGNIN_INCREMENTAL). + */ export const canDoIncrementalSync = (sync: Schema_Sync) => { - const everyCalendarHasSyncToken = sync.google?.events?.every( - (event) => event.nextSyncToken !== null, - ); - return everyCalendarHasSyncToken; + const events = sync.google?.events; + + // If no events array exists, cannot do incremental sync + if (!events || events.length === 0) { + return false; + } + + // All events must have a sync token for incremental sync + return events.every((event) => event.nextSyncToken !== null); }; export const isUsingHttps = () => getBaseURL().includes("https"); 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 d8d81fe11..f79d9ed2c 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -32,14 +32,14 @@ describe("UserMetadataService", () => { const metadata = await driver.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); - expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.importGCal).toBe("RESTART"); const persisted = await driver.fetchUserMetadata(userId); - expect(persisted.sync?.importGCal).toBe("restart"); + expect(persisted.sync?.importGCal).toBe("RESTART"); }); }); @@ -50,12 +50,12 @@ describe("UserMetadataService", () => { await driver.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); const metadata = await driver.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.importGCal).toBe("RESTART"); }); it("returns not_connected when the user never connected Google", async () => { @@ -67,7 +67,7 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: false, connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }); }); @@ -82,7 +82,7 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: false, connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }); }); @@ -95,7 +95,7 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: true, connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }); }); @@ -112,7 +112,7 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: true, connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }); isUsingHttpsSpy.mockRestore(); @@ -130,7 +130,7 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: true, connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }); expect(restartSpy).not.toHaveBeenCalled(); @@ -143,7 +143,7 @@ describe("UserMetadataService", () => { await driver.updateUserMetadata({ userId, - data: { sync: { importGCal: "errored" } }, + data: { sync: { importGCal: "ERRORED" } }, }); const metadata = await driver.fetchUserMetadata(userId); @@ -151,7 +151,7 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: true, connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }); }); @@ -164,14 +164,14 @@ describe("UserMetadataService", () => { await driver.updateUserMetadata({ userId, - data: { sync: { importGCal: "importing" } }, + data: { sync: { importGCal: "IMPORTING" } }, }); const metadata = await driver.fetchUserMetadata(userId); expect(metadata.google).toMatchObject({ connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "REPAIRING", }); expect(restartSpy).not.toHaveBeenCalled(); diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index 637662a62..3720ba396 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -109,17 +109,17 @@ class UserMetadataService { return { hasRefreshToken, connectionStatus, - syncStatus: "none", + syncStatus: "NONE", }; } const importStatus = storedMetadata.sync?.importGCal; - if (importStatus === "importing" || importStatus === "restart") { + if (importStatus === "IMPORTING" || importStatus === "RESTART") { return { hasRefreshToken, connectionStatus, - syncStatus: "repairing", + syncStatus: "REPAIRING", }; } @@ -129,22 +129,22 @@ class UserMetadataService { return { hasRefreshToken, connectionStatus, - syncStatus: "healthy", + syncStatus: "HEALTHY", }; } - if (importStatus === "errored") { + if (importStatus === "ERRORED") { return { hasRefreshToken, connectionStatus, - syncStatus: "attention", + syncStatus: "ATTENTION", }; } return { hasRefreshToken, connectionStatus, - syncStatus: "attention", + syncStatus: "ATTENTION", }; }; @@ -195,7 +195,7 @@ class UserMetadataService { ...metadata.google, hasRefreshToken, connectionStatus, - syncStatus: metadata.google?.syncStatus ?? "none", + syncStatus: metadata.google?.syncStatus ?? "NONE", }, }; } diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index fd20b6220..e86121403 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -242,7 +242,7 @@ describe("UserService", () => { Boolean(nextSyncToken), ), ).toBe(true); - expect(metadata.google?.syncStatus).toBe("healthy"); + expect(metadata.google?.syncStatus).toBe("HEALTHY"); (isUsingHttps as jest.Mock).mockRestore(); }); @@ -333,7 +333,7 @@ describe("UserService", () => { await userMetadataService.updateUserMetadata({ userId, data: { - sync: { importGCal: "completed", incrementalGCalSync: "completed" }, + sync: { importGCal: "COMPLETED", incrementalGCalSync: "COMPLETED" }, }, }); @@ -353,8 +353,8 @@ describe("UserService", () => { expect(sync).not.toHaveProperty(CalendarProvider.GOOGLE); const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("restart"); - expect(metadata.sync?.incrementalGCalSync).toBe("restart"); + expect(metadata.sync?.importGCal).toBe("RESTART"); + expect(metadata.sync?.incrementalGCalSync).toBe("RESTART"); }); }); @@ -365,13 +365,13 @@ describe("UserService", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); await userService.restartGoogleCalendarSync(userId); const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("completed"); + expect(metadata.sync?.importGCal).toBe("COMPLETED"); const calendars = await calendarService.getByUser(userId); expect(calendars.length).toBeGreaterThan(0); @@ -383,7 +383,7 @@ describe("UserService", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "completed" } }, + data: { sync: { importGCal: "COMPLETED" } }, }); const stopSpy = jest.spyOn(userService, "stopGoogleCalendarSync"); @@ -395,7 +395,7 @@ describe("UserService", () => { expect(startSpy).not.toHaveBeenCalled(); const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("completed"); + expect(metadata.sync?.importGCal).toBe("COMPLETED"); stopSpy.mockRestore(); startSpy.mockRestore(); @@ -407,7 +407,7 @@ describe("UserService", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "completed" } }, + data: { sync: { importGCal: "COMPLETED" } }, }); const stopSpy = jest @@ -423,7 +423,7 @@ describe("UserService", () => { expect(startSpy).toHaveBeenCalledWith(userId); const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("completed"); + expect(metadata.sync?.importGCal).toBe("COMPLETED"); stopSpy.mockRestore(); startSpy.mockRestore(); @@ -457,13 +457,13 @@ describe("UserService", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); await userService.restartGoogleCalendarSync(userId, { force: true }); const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("errored"); + expect(metadata.sync?.importGCal).toBe("ERRORED"); expect(await mongoService.watch.countDocuments({ user: userId })).toBe(0); stopWatchesSpy.mockRestore(); @@ -478,14 +478,14 @@ describe("UserService", () => { const metadata = await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); - expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.importGCal).toBe("RESTART"); const persisted = await userMetadataService.fetchUserMetadata(userId); - expect(persisted.sync?.importGCal).toBe("restart"); + expect(persisted.sync?.importGCal).toBe("RESTART"); }); }); @@ -496,12 +496,12 @@ describe("UserService", () => { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "restart" } }, + data: { sync: { importGCal: "RESTART" } }, }); const metadata = await userMetadataService.fetchUserMetadata(userId); - expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.importGCal).toBe("RESTART"); }); }); }); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index 53e51f5b5..07816e29d 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -213,7 +213,7 @@ class UserService { await userMetadataService.updateUserMetadata({ userId, data: { - sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, }, }); }; @@ -277,12 +277,12 @@ class UserService { const userMeta = await this.fetchUserMetadata(userId); const importStatus = userMeta.sync?.importGCal; - const isImporting = importStatus === "importing"; + const isImporting = importStatus === "IMPORTING"; const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); if (!proceed) { webSocketServer.handleImportGCalEnd(userId, { - status: "ignored", + status: "IGNORED", message: `User ${userId} gcal import is in progress or completed, ignoring this request`, }); @@ -291,7 +291,7 @@ class UserService { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "importing" } }, + data: { sync: { importGCal: "IMPORTING" } }, }); await this.stopGoogleCalendarSync(userId); @@ -299,11 +299,11 @@ class UserService { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "completed" } }, + data: { sync: { importGCal: "COMPLETED" } }, }); webSocketServer.handleImportGCalEnd(userId, { - status: "completed", + status: "COMPLETED", ...importResults, }); webSocketServer.handleBackgroundCalendarChange(userId); @@ -319,13 +319,13 @@ class UserService { await userMetadataService.updateUserMetadata({ userId, - data: { sync: { importGCal: "errored" } }, + data: { sync: { importGCal: "ERRORED" } }, }); logger.error(`Re-sync failed for user: ${userId}`, err); webSocketServer.handleImportGCalEnd(userId, { - status: "errored", + status: "ERRORED", message: `Import gCal failed for user: ${userId}`, }); } diff --git a/packages/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts deleted file mode 100644 index f5b13809b..000000000 --- a/packages/core/src/types/google-auth.types.ts +++ /dev/null @@ -1 +0,0 @@ -export type GoogleAuthIntent = "connect" | "reconnect"; diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 19e4fc025..c8a10be17 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -16,12 +16,12 @@ export interface Schema_User { lastLoggedInAt?: Date; } -type SyncStatus = "importing" | "errored" | "completed" | "restart" | null; +type SyncStatus = "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null; export type GoogleConnectionStatus = | "not_connected" | "connected" | "reconnect_required"; -export type GoogleSyncStatus = "healthy" | "repairing" | "attention" | "none"; +export type GoogleSyncStatus = "HEALTHY" | "REPAIRING" | "ATTENTION" | "NONE"; export interface UserMetadata extends SupertokensUserMetadata.JSONObject { skipOnboarding?: boolean; diff --git a/packages/core/src/types/websocket.types.ts b/packages/core/src/types/websocket.types.ts index 82deb4670..23ab7ffd1 100644 --- a/packages/core/src/types/websocket.types.ts +++ b/packages/core/src/types/websocket.types.ts @@ -5,12 +5,12 @@ import { type UserMetadata } from "@core/types/user.types"; export type ImportGCalEndPayload = | { - status: "completed"; + status: "COMPLETED"; eventsCount?: number; calendarsCount?: number; } | { - status: "errored" | "ignored"; + status: "ERRORED" | "IGNORED"; message: string; }; diff --git a/packages/core/src/util/event/event.util.ts b/packages/core/src/util/event/event.util.ts index d00331f3f..c020ad172 100644 --- a/packages/core/src/util/event/event.util.ts +++ b/packages/core/src/util/event/event.util.ts @@ -93,11 +93,11 @@ export const shouldImportGCal = (metadata: UserMetadata): boolean => { const sync = metadata.sync; switch (sync?.importGCal) { - case "importing": - case "completed": + case "IMPORTING": + case "COMPLETED": return false; - case "restart": - case "errored": + case "RESTART": + case "ERRORED": default: return true; } @@ -109,11 +109,11 @@ export const shouldDoIncrementalGCalSync = ( const sync = metadata.sync; switch (sync?.incrementalGCalSync) { - case "importing": - case "completed": + case "IMPORTING": + case "COMPLETED": return false; - case "restart": - case "errored": + case "RESTART": + case "ERRORED": default: return true; } diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 0e154fd25..2b4c289ee 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -69,9 +69,7 @@ describe("useConnectGoogle", () => { it("returns checking state when metadata is still loading", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Checking Google Calendar…", ); @@ -87,7 +85,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -107,9 +105,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); @@ -123,7 +119,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }; } @@ -143,9 +139,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Google Calendar Connected", ); @@ -160,7 +154,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -180,9 +174,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -200,7 +192,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "REPAIRING", }; } @@ -220,9 +212,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.commandAction.onSelect).toBeUndefined(); @@ -235,7 +225,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }; } @@ -255,9 +245,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Repair Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); @@ -274,9 +262,6 @@ describe("useConnectGoogle", () => { expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.setIsImportPending(true), ); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.importing(true), - ); expect(mockDispatch).not.toHaveBeenCalledWith( settingsSlice.actions.closeCmdPalette(), ); @@ -291,12 +276,12 @@ describe("useConnectGoogle", () => { ); }); - it("uses repairing state while local import is pending even before metadata catches up", () => { + it("waits for server import state instead of treating pending repair as syncing", () => { mockUseAppSelector.mockImplementation((selector) => { if (selector === selectGoogleMetadata) { return { connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -306,7 +291,7 @@ describe("useConnectGoogle", () => { if (selector === selectImportGCalState) { return { - importing: true, + importing: false, isImportPending: true, }; } @@ -316,12 +301,11 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); - expect(result.current.commandAction.isDisabled).toBe(true); - expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); - expect(result.current.sidebarStatus.isDisabled).toBe(true); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.isDisabled).toBe(false); + expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); + expect(result.current.sidebarStatus.isDisabled).toBe(false); }); it("prioritizes reconnect_required over importing state", () => { @@ -329,7 +313,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -349,9 +333,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -382,9 +364,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index bd1b19fc3..bbbf895cd 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -75,11 +75,11 @@ const getGoogleUiState = ({ return "checking"; } - if (connectionStatus === "connected" && syncStatus === "repairing") { + if (connectionStatus === "connected" && syncStatus === "REPAIRING") { return "connected_repairing"; } - if (connectionStatus === "connected" && syncStatus === "attention") { + if (connectionStatus === "connected" && syncStatus === "ATTENTION") { return "connected_attention"; } @@ -200,14 +200,11 @@ export const useConnectGoogle = () => { ) => RootState["sync"]["importGCal"], ); const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; - const syncStatus = googleMetadata?.syncStatus ?? "none"; - const { login } = useGoogleAuth({ - googleAuthIntent: - connectionStatus === "reconnect_required" ? "reconnect" : undefined, - }); + const syncStatus = googleMetadata?.syncStatus ?? "NONE"; + const { login } = useGoogleAuth(); const onOpenGoogleAuth = useCallback(() => { - login(); + void login(); dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); @@ -215,7 +212,6 @@ export const useConnectGoogle = () => { const run = async () => { dispatch(importGCalSlice.actions.clearImportResults(undefined)); dispatch(importGCalSlice.actions.setIsImportPending(true)); - dispatch(importGCalSlice.actions.importing(true)); try { await SyncApi.importGCal({ force: true }); @@ -249,7 +245,7 @@ export const useConnectGoogle = () => { const state = getGoogleUiState({ connectionStatus, syncStatus, - isImporting: importGCal.importing || importGCal.isImportPending, + isImporting: importGCal.importing, isCheckingStatus, }); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index 1797467e5..77fca6d1d 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts @@ -150,48 +150,22 @@ describe("useGoogleAuth", () => { expect(mockMarkUserAsAuthenticated).toHaveBeenCalled(); expect(mockSetAuthenticated).toHaveBeenCalledWith(true); expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); - }); - - it("passes reconnect intent through authentication when requested", async () => { - let onSuccessCallback: ((data: SignInUpInput) => Promise) | undefined; - - mockUseGoogleLogin.mockImplementation(({ onSuccess }) => { - onSuccessCallback = onSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - renderHook(() => useGoogleAuth({ googleAuthIntent: "reconnect" })); - - if (onSuccessCallback) { - await onSuccessCallback({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - } - - await waitFor(() => { - expect(mockAuthenticate).toHaveBeenCalledWith( - expect.objectContaining({ - googleAuthIntent: "reconnect", - }), - ); - }); + expect(mockDispatchFn).toHaveBeenCalledWith( + expect.objectContaining({ + type: "async/importGCal/setIsImportPending", + payload: true, + }), + ); + expect(mockDispatchFn).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "async/importGCal/importing", + payload: true, + }), + ); }); describe("onStart callback", () => { - it("shows overlay immediately when login starts and clears session-expired toast", () => { + it("shows overlay immediately when login starts and clears prior import results", () => { mockUseGoogleLogin.mockReturnValue({ login: mockLogin, loading: false, @@ -208,8 +182,7 @@ describe("useGoogleAuth", () => { ); expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ - type: "async/importGCal/setIsImportPending", - payload: true, + type: "async/importGCal/clearImportResults", }), ); const { toast } = jest.requireMock("react-toastify"); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index f3c8b06ba..62b8c6c6c 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -1,6 +1,5 @@ import { batch } from "react-redux"; import { toast } from "react-toastify"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; import { authenticate, @@ -28,28 +27,19 @@ import { } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; -interface UseGoogleAuthOptions { - googleAuthIntent?: GoogleAuthIntent; -} - -export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { +export function useGoogleAuth() { const dispatch = useAppDispatch(); const { setAuthenticated } = useSession(); - const { googleAuthIntent } = options; const googleLogin = useGoogleAuthWithOverlay({ onStart: () => { dismissErrorToast(SESSION_EXPIRED_TOAST_ID); dispatch(startAuthenticating()); - dispatch(importGCalSlice.actions.setIsImportPending(true)); dispatch(importGCalSlice.actions.clearImportResults(undefined)); }, onSuccess: async (data) => { try { - const authPayload: SignInUpInput = - googleAuthIntent === "reconnect" - ? { ...data, googleAuthIntent } - : data; + const authPayload: SignInUpInput = data; const authResult = await authenticate(authPayload); if (!authResult.success) { console.error(authResult.error); @@ -66,17 +56,14 @@ export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { markUserAsAuthenticated(); setAuthenticated(true); - void refreshUserMetadata(); - // Batch these dispatches to ensure they update in the same render cycle, - // preventing a flash where isAuthenticating=false but importing=false batch(() => { dispatch(authSuccess()); - // Now that OAuth is complete, indicate that calendar import is starting - dispatch(importGCalSlice.actions.importing(true)); dispatch(importGCalSlice.actions.setIsImportPending(true)); }); + void refreshUserMetadata(); + const syncResult = await syncLocalEvents(); if (syncResult.success && syncResult.syncedCount > 0) { diff --git a/packages/web/src/auth/session/user-metadata.util.test.ts b/packages/web/src/auth/session/user-metadata.util.test.ts index cd0d195a6..174853bff 100644 --- a/packages/web/src/auth/session/user-metadata.util.test.ts +++ b/packages/web/src/auth/session/user-metadata.util.test.ts @@ -30,7 +30,7 @@ describe("refreshUserMetadata", () => { const metadata = { google: { connectionStatus: "connected" as const, - syncStatus: "healthy" as const, + syncStatus: "HEALTHY" as const, }, }; api.getMetadata.mockResolvedValue(metadata); diff --git a/packages/web/src/auth/session/user-metadata.util.ts b/packages/web/src/auth/session/user-metadata.util.ts index d5a2c6726..741f8abe8 100644 --- a/packages/web/src/auth/session/user-metadata.util.ts +++ b/packages/web/src/auth/session/user-metadata.util.ts @@ -10,7 +10,7 @@ export const refreshUserMetadata = async (): Promise => { return refreshUserMetadataRequest; } - store.dispatch(userMetadataSlice.actions.setLoading()); + store.dispatch(userMetadataSlice.actions.setLoading(undefined)); refreshUserMetadataRequest = UserApi.getMetadata() .then((metadata) => { @@ -23,12 +23,12 @@ export const refreshUserMetadata = async (): Promise => { status === Status.UNAUTHORIZED || status === Status.FORBIDDEN; if (isUnauthorized) { - store.dispatch(userMetadataSlice.actions.clear()); + store.dispatch(userMetadataSlice.actions.clear(undefined)); return; } console.error("Failed to refresh user metadata", error); - store.dispatch(userMetadataSlice.actions.finishLoading()); + store.dispatch(userMetadataSlice.actions.finishLoading(undefined)); }) .finally(() => { refreshUserMetadataRequest = null; diff --git a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx index 4abb50ccb..fab6c8ae9 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx @@ -26,9 +26,7 @@ describe("SessionExpiredToast", () => { it("renders session-expired message and reconnect button", () => { render(); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect( screen.getByText("Google Calendar connection expired. Please reconnect."), ).toBeInTheDocument(); diff --git a/packages/web/src/common/utils/toast/session-expired.toast.tsx b/packages/web/src/common/utils/toast/session-expired.toast.tsx index eb6d1f52a..e66826b1e 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.tsx @@ -6,7 +6,7 @@ interface SessionExpiredToastProps { } export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { - const { login } = useGoogleAuth({ googleAuthIntent: "reconnect" }); + const { login } = useGoogleAuth(); const handleReconnect = () => { login(); diff --git a/packages/web/src/components/oauth/ouath.types.ts b/packages/web/src/components/oauth/ouath.types.ts index eea6e7479..04c745cde 100644 --- a/packages/web/src/components/oauth/ouath.types.ts +++ b/packages/web/src/components/oauth/ouath.types.ts @@ -1,10 +1,8 @@ import { type CodeResponse } from "@react-oauth/google"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; export interface SignInUpInput { thirdPartyId: string; clientType: "web"; - googleAuthIntent?: GoogleAuthIntent; redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: Omit< diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index df24eee4e..1a2ca7733 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -146,10 +146,10 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); // Simulate socket reconnecting while import is still running - metadataHandler?.({ sync: { importGCal: "importing" } }); + metadataHandler?.({ sync: { importGCal: "IMPORTING" } }); expect(mockDispatch).toHaveBeenCalledWith( - userMetadataSlice.actions.set({ sync: { importGCal: "importing" } }), + userMetadataSlice.actions.set({ sync: { importGCal: "IMPORTING" } }), ); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(true), @@ -169,10 +169,10 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); - metadataHandler?.({ sync: { importGCal: "completed" } }); + metadataHandler?.({ sync: { importGCal: "COMPLETED" } }); expect(mockDispatch).toHaveBeenCalledWith( - userMetadataSlice.actions.set({ sync: { importGCal: "completed" } }), + userMetadataSlice.actions.set({ sync: { importGCal: "COMPLETED" } }), ); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -197,10 +197,10 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); - metadataHandler?.({ sync: { importGCal: "errored" } }); + metadataHandler?.({ sync: { importGCal: "ERRORED" } }); expect(mockDispatch).toHaveBeenCalledWith( - userMetadataSlice.actions.set({ sync: { importGCal: "errored" } }), + userMetadataSlice.actions.set({ sync: { importGCal: "ERRORED" } }), ); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -221,13 +221,34 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); - metadataHandler?.({ sync: { importGCal: "restart" } }); + metadataHandler?.({ + sync: { importGCal: "RESTART" }, + google: { connectionStatus: "connected" }, + }); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.request(undefined as never), ); }); + it("does not auto-request an import when reconnect is required", () => { + let metadataHandler: ((metadata: unknown) => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === USER_METADATA) { + metadataHandler = handler; + } + }); + + renderHook(() => useGcalSync()); + + metadataHandler?.({ + sync: { importGCal: "RESTART" }, + google: { connectionStatus: "reconnect_required" }, + }); + + expect(importGCalSlice.actions.request).not.toHaveBeenCalled(); + }); + it("does not auto-request an import when metadata says errored", () => { let metadataHandler: ((metadata: unknown) => void) | undefined; (socket.on as jest.Mock).mockImplementation((event, handler) => { @@ -239,8 +260,8 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); metadataHandler?.({ - sync: { importGCal: "errored" }, - google: { connectionStatus: "connected", syncStatus: "attention" }, + sync: { importGCal: "ERRORED" }, + google: { connectionStatus: "connected", syncStatus: "ATTENTION" }, }); expect(importGCalSlice.actions.request).not.toHaveBeenCalled(); @@ -285,7 +306,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); importEndHandler?.({ - status: "completed", + status: "COMPLETED", eventsCount: 10, calendarsCount: 2, }); @@ -319,7 +340,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); - importEndHandler?.({ status: "completed", eventsCount: 10 }); + importEndHandler?.({ status: "COMPLETED", eventsCount: 10 }); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -351,7 +372,7 @@ describe("useGcalSync", () => { // Event arrives - should process correctly with ref pattern importEndHandler?.({ - status: "completed", + status: "COMPLETED", eventsCount: 10, calendarsCount: 2, }); @@ -381,7 +402,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); importEndHandler?.({ - status: "ignored", + status: "IGNORED", message: "User test-user gcal import is in progress or completed, ignoring this request", }); @@ -436,7 +457,7 @@ describe("useGcalSync", () => { // Phase 3: Backend signals import complete with successful response const successfulResponse: ImportGCalEndPayload = { - status: "completed", + status: "COMPLETED", eventsCount: 25, calendarsCount: 3, }; @@ -481,7 +502,7 @@ describe("useGcalSync", () => { // Import completes successfully handlers[IMPORT_GCAL_END]({ - status: "completed", + status: "COMPLETED", eventsCount: 100, calendarsCount: 5, }); @@ -508,7 +529,7 @@ describe("useGcalSync", () => { handlers[IMPORT_GCAL_START](true); jest.advanceTimersByTime(100); // Very fast import handlers[IMPORT_GCAL_END]({ - status: "completed", + status: "COMPLETED", eventsCount: 2, calendarsCount: 1, }); @@ -545,7 +566,7 @@ describe("useGcalSync", () => { mockDispatch.mockClear(); // Backend sends empty response (edge case) - handlers[IMPORT_GCAL_END]({ status: "completed" }); + handlers[IMPORT_GCAL_END]({ status: "COMPLETED" }); // Should still hide spinner and set empty results expect(mockDispatch).toHaveBeenCalledWith( @@ -572,7 +593,7 @@ describe("useGcalSync", () => { mockDispatch.mockClear(); handlers[IMPORT_GCAL_END]({ - status: "completed", + status: "COMPLETED", eventsCount: 50, calendarsCount: 4, }); @@ -604,7 +625,7 @@ describe("useGcalSync", () => { mockDispatch.mockClear(); handlers[IMPORT_GCAL_END]({ - status: "errored", + status: "ERRORED", message: "Incremental Google Calendar sync failed for user: test-user", }); diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 18e452bc8..832247b3e 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -47,16 +47,16 @@ export const useGcalSync = () => { return; } - if (payload?.status === "errored") { + if (payload?.status === "ERRORED") { dispatch(importGCalSlice.actions.setImportError(payload.message)); return; } - if (payload?.status === "ignored") { + if (payload?.status === "IGNORED") { return; } - if (payload?.status === "completed") { + if (payload?.status === "COMPLETED") { dispatch( importGCalSlice.actions.setImportResults({ eventsCount: payload.eventsCount, @@ -81,15 +81,17 @@ export const useGcalSync = () => { const onMetadataFetch = useCallback( (metadata: UserMetadata) => { const importStatus = metadata.sync?.importGCal; - const isBackendImporting = importStatus === "importing"; - const shouldAutoImport = importStatus === "restart"; + const connectionStatus = metadata.google?.connectionStatus; + const isBackendImporting = importStatus === "IMPORTING"; + const shouldAutoImport = + importStatus === "RESTART" && connectionStatus === "connected"; dispatch(userMetadataSlice.actions.set(metadata)); if (isImportPendingRef.current) { if (isBackendImporting) { dispatch(importGCalSlice.actions.importing(true)); - } else if (importStatus === "completed") { + } else if (importStatus === "COMPLETED") { dispatch(importGCalSlice.actions.importing(false)); dispatch(importGCalSlice.actions.setIsImportPending(false)); dispatch( @@ -97,7 +99,7 @@ export const useGcalSync = () => { reason: Sync_AsyncStateContextReason.IMPORT_COMPLETE, }), ); - } else if (importStatus === "errored") { + } else if (importStatus === "ERRORED") { dispatch(importGCalSlice.actions.importing(false)); dispatch(importGCalSlice.actions.setIsImportPending(false)); } diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx index c0b8fefcb..343364090 100644 --- a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx @@ -200,7 +200,7 @@ describe("GCal Re-Authentication Flow", () => { await act(async () => { importEndCallback?.({ - status: "completed", + status: "COMPLETED", eventsCount: 15, calendarsCount: 3, }); @@ -248,7 +248,7 @@ describe("GCal Re-Authentication Flow", () => { // Backend sends IMPORT_GCAL_END with zero events (valid response) await act(async () => { importEndCallback?.({ - status: "completed", + status: "COMPLETED", eventsCount: 0, calendarsCount: 1, }); @@ -286,7 +286,7 @@ describe("GCal Re-Authentication Flow", () => { }); await act(async () => { - metadataCallback?.({ sync: { importGCal: "completed" } }); + metadataCallback?.({ sync: { importGCal: "COMPLETED" } }); }); await act(async () => { @@ -359,7 +359,7 @@ describe("GCal Re-Authentication Flow", () => { // Backend completes import await act(async () => { importEndCallback?.({ - status: "completed", + status: "COMPLETED", eventsCount: 42, calendarsCount: 2, }); @@ -416,7 +416,7 @@ describe("GCal Re-Authentication Flow", () => { // Backend responds successfully await act(async () => { importEndCallback?.({ - status: "completed", + status: "COMPLETED", eventsCount: 10, calendarsCount: 1, }); @@ -452,7 +452,7 @@ describe("GCal Re-Authentication Flow", () => { await act(async () => { importEndCallback?.({ - status: "errored", + status: "ERRORED", message: "Incremental Google Calendar sync failed for user: test-user", }); @@ -493,7 +493,7 @@ describe("GCal Re-Authentication Flow", () => { await act(async () => { importEndCallback?.({ - status: "ignored", + status: "IGNORED", message: "User test-user gcal import is in progress or completed, ignoring this request", }); @@ -502,7 +502,7 @@ describe("GCal Re-Authentication Flow", () => { expect(store.getState().sync.importGCal.isImportPending).toBe(true); await act(async () => { - metadataCallback?.({ sync: { importGCal: "completed" } }); + metadataCallback?.({ sync: { importGCal: "COMPLETED" } }); }); await act(async () => { @@ -554,7 +554,7 @@ describe("GCal Re-Authentication Flow", () => { // Event arrives - with the ref pattern fix, this should process correctly await act(async () => { importEndCallback?.({ - status: "completed", + status: "COMPLETED", eventsCount: 25, calendarsCount: 4, }); diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx index f4a73c9e2..dfa085fa5 100644 --- a/packages/web/src/socket/provider/SocketProvider.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.test.tsx @@ -82,7 +82,7 @@ describe("SocketProvider", () => { await act(async () => { importEndCallback?.({ - status: "completed", + status: "COMPLETED", eventsCount: 10, calendarsCount: 2, }); @@ -120,7 +120,7 @@ describe("SocketProvider", () => { expect(importEndCallback).toBeDefined(); }); - importEndCallback?.({ status: "completed" }); + importEndCallback?.({ status: "COMPLETED" }); const state = store.getState(); expect(state.sync.importGCal.importResults).toBeNull(); diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx index de5b23615..38ab0db0d 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx @@ -59,7 +59,7 @@ describe("SidebarIconRow", () => { current: { google: { connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }, }, }, @@ -83,7 +83,7 @@ describe("SidebarIconRow", () => { current: { google: { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }, }, }, @@ -107,7 +107,7 @@ describe("SidebarIconRow", () => { current: { google: { connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }, }, }, @@ -131,7 +131,7 @@ describe("SidebarIconRow", () => { current: { google: { connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "REPAIRING", }, }, }, @@ -155,7 +155,7 @@ describe("SidebarIconRow", () => { current: { google: { connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }, }, }, diff --git a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx index 7b4dc9edd..60ed378c6 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx @@ -106,7 +106,7 @@ describe("CmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }, }, }, @@ -126,7 +126,7 @@ describe("CmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }, }, }, diff --git a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx index 0c5ee174a..0900a718b 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -342,7 +342,7 @@ describe("DayCmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }, }, }, @@ -365,7 +365,7 @@ describe("DayCmdPalette", () => { current: { google: { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }, }, }, @@ -392,7 +392,7 @@ describe("DayCmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "REPAIRING", }, }, }, @@ -415,7 +415,7 @@ describe("DayCmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }, }, }, diff --git a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx index e01d7cdb0..12edec25f 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx @@ -162,7 +162,7 @@ describe("NowCmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }, }, }, @@ -182,7 +182,7 @@ describe("NowCmdPalette", () => { current: { google: { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }, }, }, @@ -205,7 +205,7 @@ describe("NowCmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "REPAIRING", }, }, }, @@ -226,7 +226,7 @@ describe("NowCmdPalette", () => { current: { google: { connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }, }, }, diff --git a/playwright.config.ts b/playwright.config.ts index e59bb0422..74630fb78 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,7 @@ const TEST_PORT = 9150; export default defineConfig({ testDir: "./e2e", timeout: 30_000, + workers: 2, expect: { timeout: 10_000, },