From 07fbb8685393b88b670eb30d1a731767ee040af5 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 20:54:01 -0700 Subject: [PATCH 01/13] refactor(auth): update Google connection handling and auth mode determination - Renamed `reconnectGoogleForSession` to `repairGoogleConnection` to better reflect its purpose in handling Google connection repairs. - Enhanced the logic for determining the auth mode based on server-side state, replacing reliance on frontend intent. - Updated documentation to clarify the new auth mode classification and deprecated fields. - Refactored related tests to align with the new method names and logic, ensuring comprehensive coverage of the updated functionality. --- docs/google-sync-and-websocket-flow.md | 21 +- .../auth/schemas/reconnect-google.schemas.ts | 4 +- .../services/compass.auth.service.test.ts | 34 +-- .../src/auth/services/compass.auth.service.ts | 15 +- .../google.auth.success.service.test.ts | 283 +++++++++++++++--- .../google/google.auth.success.service.ts | 159 ++++++++-- .../middleware/supertokens.middleware.util.ts | 18 ++ packages/core/src/types/google-auth.types.ts | 11 + 8 files changed, 456 insertions(+), 89 deletions(-) diff --git a/docs/google-sync-and-websocket-flow.md b/docs/google-sync-and-websocket-flow.md index b446feb2f..7bcfa373a 100644 --- a/docs/google-sync-and-websocket-flow.md +++ b/docs/google-sync-and-websocket-flow.md @@ -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, not frontend intent: + +| 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: The `googleAuthIntent` field from frontend is deprecated and no longer authoritative for routing. Primary files: 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..14ce90c24 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,20 +34,20 @@ 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( @@ -55,14 +55,14 @@ describe("CompassAuthService", () => { ); expect(metadata.sync?.importGCal).toBe("restart"); expect(metadata.sync?.incrementalGCalSync).toBe("restart"); - expect(restartSpy).toHaveBeenCalledWith(sessionUserId); + 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..822937e03 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -126,8 +126,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 +144,7 @@ class CompassAuthService { cUserId, gUser: validatedGUser, refreshToken, - } = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens); + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); await userService.reconnectGoogleCredentials( 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..b50bf80a3 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(); @@ -93,11 +98,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(), @@ -116,50 +123,232 @@ 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, + sessionUserId: null, + }; + + 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, + sessionUserId: null, + }; + + 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, + sessionUserId: null, + }; + + 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, + loginMethodsLength: 1, sessionUserId: null, }; 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, + sessionUserId: null, + }, + 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, + sessionUserId: null, + }, + 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, + sessionUserId: null, + }, + 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..0207da4ce 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,33 @@ export type GoogleSignInSuccess = { createdNewRecipeUser: boolean; recipeUserId: string; loginMethodsLength: number; + /** + * @deprecated This field is transitional. Auth mode is now determined + * server-side based on refresh token presence and sync health. + * Will be removed once frontend stops sending googleAuthIntent. + */ 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 +53,77 @@ 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, + }; +} + +/** + * Logs the auth decision for observability. + */ +function logAuthDecision( + decision: AuthDecision, + hasSession: boolean, + googleUserId: string, +): void { + logger.info("Google auth decision", { + auth_mode: decision.authMode, + created_new_recipe_user: decision.createdNewRecipeUser, + has_stored_refresh_token: decision.hasStoredRefreshToken, + has_healthy_sync: decision.hasHealthySync, + has_session: hasSession, + compass_user_id: decision.compassUserId, + google_user_id: googleUserId, + }); +} + export async function handleGoogleAuth( success: GoogleSignInSuccess, authService: GoogleSignInSuccessAuthService, @@ -39,25 +137,50 @@ export async function handleGoogleAuth( 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); + + // Log the decision for observability + logAuthDecision(decision, sessionUserId !== null, googleUserId); - 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 createdNewRecipeUser is false", { + google_user_id: googleUserId, + recipe_user_id: recipeUserId, + }); + } + 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.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index beb365519..f3f092e2c 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -20,6 +20,13 @@ export type CreateGoogleSignInResponse = | { status: Exclude } | GoogleThirdPartySignInUpSuccess; +/** + * @deprecated This function is transitional. Auth mode is now determined + * server-side in handleGoogleAuth() based on refresh token presence and + * sync health. The googleAuthIntent is no longer authoritative for routing. + * + * Kept temporarily for backward compatibility during transition period. + */ export function getGoogleAuthIntent( value: unknown, ): GoogleAuthIntent | undefined { @@ -30,6 +37,14 @@ export function getGoogleAuthIntent( return undefined; } +/** + * @deprecated This function is transitional. Auth mode determination has + * moved to handleGoogleAuth() where it uses server-side signals (refresh + * token presence, sync health) instead of frontend-provided intent. + * + * The sessionUserId is still passed through for logging purposes but is + * no longer the primary routing signal for reconnect flows. + */ export function resolveGoogleSessionUserId({ sessionUserId, googleAuthIntent, @@ -41,6 +56,9 @@ export function resolveGoogleSessionUserId({ createdNewRecipeUser: boolean; recipeUserId: string; }): string | null { + // Note: This function's return value is no longer used for auth routing. + // Auth mode is now determined server-side in handleGoogleAuth(). + // We still pass sessionUserId through for observability/logging. if (sessionUserId) { return sessionUserId; } diff --git a/packages/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts index f5b13809b..9da564cc0 100644 --- a/packages/core/src/types/google-auth.types.ts +++ b/packages/core/src/types/google-auth.types.ts @@ -1 +1,12 @@ +/** + * @deprecated This type is transitional. Auth mode is now determined + * server-side in handleGoogleAuth() based on refresh token presence and + * sync health, not frontend-provided intent. + * + * The frontend may still send this value, but it is no longer authoritative + * for routing auth flows. Backend determines auth mode using: + * - User existence (via findCompassUserBy) + * - Refresh token presence (user.google.gRefreshToken) + * - Sync health (canDoIncrementalSync) + */ export type GoogleAuthIntent = "connect" | "reconnect"; From 29f21fbc320ac1f7ff184822cbf0537ab859d724 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 20:54:22 -0700 Subject: [PATCH 02/13] refactor(types): streamline interface definitions for better readability - Updated various interface definitions across the codebase to improve formatting and readability by aligning type extensions and removing unnecessary line breaks. - Adjusted interfaces in event, user, and component types to follow a consistent style, enhancing maintainability and clarity. --- packages/core/src/types/event.types.ts | 12 ++++-------- packages/core/src/types/user.types.ts | 9 +++++---- packages/web/src/__tests__/__mocks__/mock.render.tsx | 3 ++- packages/web/src/common/types/api.types.ts | 5 ++--- .../components/AuthModal/components/AuthInput.tsx | 6 ++---- packages/web/src/components/DND/Draggable.tsx | 9 +++++---- packages/web/src/components/IconButton/styled.ts | 3 ++- packages/web/src/components/Input/Input.tsx | 3 +-- packages/web/src/components/Textarea/types.ts | 4 +++- .../web/src/views/Forms/ActionsMenu/MenuItem.tsx | 3 ++- 10 files changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/core/src/types/event.types.ts b/packages/core/src/types/event.types.ts index 7e8756dbd..1ae9d5033 100644 --- a/packages/core/src/types/event.types.ts +++ b/packages/core/src/types/event.types.ts @@ -104,19 +104,15 @@ export type Schema_Event_Regular = Omit< "recurrence" | "gRecurringEventId" >; -export interface Schema_Event_Recur_Base extends Omit< - Schema_Event, - "recurrence" | "gRecurringEventId" -> { +export interface Schema_Event_Recur_Base + extends Omit { recurrence: { rule: string[]; // No eventId since this is the base recurring event }; } -export interface Schema_Event_Recur_Instance extends Omit< - Schema_Event, - "recurrence" -> { +export interface Schema_Event_Recur_Instance + extends Omit { recurrence: { eventId: string; // No rule since this is an instance of the recurring event }; diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 19e4fc025..ef43566dc 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -36,10 +36,11 @@ export interface UserMetadata extends SupertokensUserMetadata.JSONObject { }; } -export interface UserProfile extends Pick< - WithCompassId, - "firstName" | "lastName" | "name" | "email" | "locale" -> { +export interface UserProfile + extends Pick< + WithCompassId, + "firstName" | "lastName" | "name" | "email" | "locale" + > { picture: string; userId: string; } diff --git a/packages/web/src/__tests__/__mocks__/mock.render.tsx b/packages/web/src/__tests__/__mocks__/mock.render.tsx index 607fbf969..ed2f8b033 100644 --- a/packages/web/src/__tests__/__mocks__/mock.render.tsx +++ b/packages/web/src/__tests__/__mocks__/mock.render.tsx @@ -29,7 +29,8 @@ interface CustomRenderOptions extends RenderOptions { } interface CustomRenderHookOptions - extends CustomRenderOptions, Omit, "wrapper"> {} + extends CustomRenderOptions, + Omit, "wrapper"> {} const TestProviders = (props?: { router?: RouterProviderProps["router"]; diff --git a/packages/web/src/common/types/api.types.ts b/packages/web/src/common/types/api.types.ts index dc47b7ba2..629d67065 100644 --- a/packages/web/src/common/types/api.types.ts +++ b/packages/web/src/common/types/api.types.ts @@ -15,9 +15,8 @@ export interface Filters_Pagination { export type Options_FilterSort = Filters_Pagination & Options_Sort; -export interface Response_HttpPaginatedSuccess< - Data, -> extends Filters_Pagination { +export interface Response_HttpPaginatedSuccess + extends Filters_Pagination { data: Data; count: number; [key: string]: unknown | undefined; diff --git a/packages/web/src/components/AuthModal/components/AuthInput.tsx b/packages/web/src/components/AuthModal/components/AuthInput.tsx index b3350e413..c0cbe8382 100644 --- a/packages/web/src/components/AuthModal/components/AuthInput.tsx +++ b/packages/web/src/components/AuthModal/components/AuthInput.tsx @@ -1,10 +1,8 @@ import clsx from "clsx"; import { type InputHTMLAttributes, forwardRef, useId } from "react"; -interface AuthInputProps extends Omit< - InputHTMLAttributes, - "className" -> { +interface AuthInputProps + extends Omit, "className"> { /** Label text displayed above the input. Omit for placeholder-only style. */ label?: string; /** Accessible name when label is hidden (required when label is omitted) */ diff --git a/packages/web/src/components/DND/Draggable.tsx b/packages/web/src/components/DND/Draggable.tsx index a3aba839b..a3d39bf4f 100644 --- a/packages/web/src/components/DND/Draggable.tsx +++ b/packages/web/src/components/DND/Draggable.tsx @@ -25,10 +25,11 @@ export interface DraggableDNDData { view: "day" | "week" | "now"; } -export interface DNDChildProps extends Pick< - ReturnType, - "over" | "listeners" | "isDragging" -> { +export interface DNDChildProps + extends Pick< + ReturnType, + "over" | "listeners" | "isDragging" + > { id: UniqueIdentifier; setDisabled?: (disabled: boolean) => void; } diff --git a/packages/web/src/components/IconButton/styled.ts b/packages/web/src/components/IconButton/styled.ts index d6e3703b1..95163b677 100644 --- a/packages/web/src/components/IconButton/styled.ts +++ b/packages/web/src/components/IconButton/styled.ts @@ -8,7 +8,8 @@ const sizeMap: Record = { large: 34, }; -export interface IconButtonProps extends React.ButtonHTMLAttributes { +export interface IconButtonProps + extends React.ButtonHTMLAttributes { size?: IconButtonSize; } diff --git a/packages/web/src/components/Input/Input.tsx b/packages/web/src/components/Input/Input.tsx index 6e933a4b3..3918148f5 100644 --- a/packages/web/src/components/Input/Input.tsx +++ b/packages/web/src/components/Input/Input.tsx @@ -12,8 +12,7 @@ import { Focusable } from "../Focusable/Focusable"; import { StyledInput, type Props as StyledProps } from "./styled"; export interface Props - extends - ClassNamedComponent, + extends ClassNamedComponent, UnderlinedInput, StyledProps, HTMLAttributes { diff --git a/packages/web/src/components/Textarea/types.ts b/packages/web/src/components/Textarea/types.ts index 7b8d4dc29..c48e3a315 100644 --- a/packages/web/src/components/Textarea/types.ts +++ b/packages/web/src/components/Textarea/types.ts @@ -5,6 +5,8 @@ import { } from "@web/common/types/component.types"; export interface TextareaProps - extends UnderlinedInput, ClassNamedComponent, TextareaAutosizeProps { + extends UnderlinedInput, + ClassNamedComponent, + TextareaAutosizeProps { heightFitsContent?: boolean; } diff --git a/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx b/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx index e095af2a8..2d5cff4b3 100644 --- a/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx +++ b/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx @@ -10,7 +10,8 @@ import { import { useMenuContext } from "./ActionsMenu"; import { StyledMenuItem } from "./styled"; -export interface MenuItemProps extends React.ButtonHTMLAttributes { +export interface MenuItemProps + extends React.ButtonHTMLAttributes { /** * Content to render inside the delayed tooltip. If omitted, the tooltip is disabled. */ From 2b3b0c1242c559770ccbf16a37d64d5b41b0ca7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 03:59:49 +0000 Subject: [PATCH 03/13] refactor(backend): improve canDoIncrementalSync edge case handling - Add explicit handling for undefined/empty events arrays - Add comprehensive JSDoc documentation - Ensure function returns false when sync data is missing or incomplete - This ensures reconnect_repair path is correctly triggered when sync is unhealthy Co-authored-by: Tyler Dane --- packages/backend/src/sync/util/sync.util.ts | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/sync/util/sync.util.ts b/packages/backend/src/sync/util/sync.util.ts index 9305295d4..4b43e8d3b 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"); From 2c083f385526236a7eac34e3488f0690e83e32fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 04:02:01 +0000 Subject: [PATCH 04/13] fix(backend): correct warning log message for auth edge case Co-authored-by: Tyler Dane --- .../src/auth/services/google/google.auth.success.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0207da4ce..41991f436 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 @@ -154,9 +154,11 @@ export async function handleGoogleAuth( 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 createdNewRecipeUser is false", { + 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; From c03987272f63398c305e7db04fdb10cd1f339ab1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 04:07:39 +0000 Subject: [PATCH 05/13] fix(backend): remove dead determineAuthMethod function Co-authored-by: Tyler Dane --- .../src/auth/services/compass.auth.service.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index 822937e03..d4f955b13 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -31,28 +31,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); From 3b70a0d1f5350779976068c45cd26c59d23f157b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 04:12:58 +0000 Subject: [PATCH 06/13] fix(backend): remove unused imports from compass.auth.service Co-authored-by: Tyler Dane --- packages/backend/src/auth/services/compass.auth.service.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index d4f955b13..be294286a 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"; From ec7584b9e926d9a955ed1fc4b1b1523c09d7dcec Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 15:25:27 -0700 Subject: [PATCH 07/13] refactor(auth): remove deprecated sessionUserId handling and related functions - Eliminated the sessionUserId field from GoogleSignInSuccess type and related functions, as auth mode is now determined server-side. - Removed the resolveGoogleSessionUserId function and its associated tests, streamlining the Google authentication process. - Updated the useGoogleAuth hook to reflect these changes, ensuring a cleaner implementation without reliance on frontend-provided intent. Co-authored-by: Tyler Dane --- .../google/google.auth.success.service.ts | 29 ------ .../supertokens.middleware.util.test.ts | 96 ++++--------------- .../middleware/supertokens.middleware.util.ts | 42 -------- packages/core/src/types/google-auth.types.ts | 12 --- .../web/src/auth/hooks/oauth/useGoogleAuth.ts | 13 +-- 5 files changed, 21 insertions(+), 171 deletions(-) delete mode 100644 packages/core/src/types/google-auth.types.ts 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 41991f436..c076ed04b 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 @@ -12,12 +12,6 @@ export type GoogleSignInSuccess = { createdNewRecipeUser: boolean; recipeUserId: string; loginMethodsLength: number; - /** - * @deprecated This field is transitional. Auth mode is now determined - * server-side based on refresh token presence and sync health. - * Will be removed once frontend stops sending googleAuthIntent. - */ - sessionUserId: string | null; }; /** @@ -105,25 +99,6 @@ async function determineAuthMode( }; } -/** - * Logs the auth decision for observability. - */ -function logAuthDecision( - decision: AuthDecision, - hasSession: boolean, - googleUserId: string, -): void { - logger.info("Google auth decision", { - auth_mode: decision.authMode, - created_new_recipe_user: decision.createdNewRecipeUser, - has_stored_refresh_token: decision.hasStoredRefreshToken, - has_healthy_sync: decision.hasHealthySync, - has_session: hasSession, - compass_user_id: decision.compassUserId, - google_user_id: googleUserId, - }); -} - export async function handleGoogleAuth( success: GoogleSignInSuccess, authService: GoogleSignInSuccessAuthService, @@ -134,7 +109,6 @@ export async function handleGoogleAuth( createdNewRecipeUser, recipeUserId, loginMethodsLength, - sessionUserId, } = success; const googleUserId = providerUser.sub; @@ -145,9 +119,6 @@ export async function handleGoogleAuth( // Determine auth mode based on server-side state const decision = await determineAuthMode(googleUserId, createdNewRecipeUser); - // Log the decision for observability - logAuthDecision(decision, sessionUserId !== null, googleUserId); - switch (decision.authMode) { case "signup": { const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; 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 f3f092e2c..463f4d34f 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; @@ -37,43 +36,8 @@ export function getGoogleAuthIntent( return undefined; } -/** - * @deprecated This function is transitional. Auth mode determination has - * moved to handleGoogleAuth() where it uses server-side signals (refresh - * token presence, sync health) instead of frontend-provided intent. - * - * The sessionUserId is still passed through for logging purposes but is - * no longer the primary routing signal for reconnect flows. - */ -export function resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser, - recipeUserId, -}: { - sessionUserId: string | null; - googleAuthIntent?: GoogleAuthIntent; - createdNewRecipeUser: boolean; - recipeUserId: string; -}): string | null { - // Note: This function's return value is no longer used for auth routing. - // Auth mode is now determined server-side in handleGoogleAuth(). - // We still pass sessionUserId through for observability/logging. - 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; @@ -83,11 +47,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/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts deleted file mode 100644 index 9da564cc0..000000000 --- a/packages/core/src/types/google-auth.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @deprecated This type is transitional. Auth mode is now determined - * server-side in handleGoogleAuth() based on refresh token presence and - * sync health, not frontend-provided intent. - * - * The frontend may still send this value, but it is no longer authoritative - * for routing auth flows. Backend determines auth mode using: - * - User existence (via findCompassUserBy) - * - Refresh token presence (user.google.gRefreshToken) - * - Sync health (canDoIncrementalSync) - */ -export type GoogleAuthIntent = "connect" | "reconnect"; diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index f3c8b06ba..38b580dfe 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,14 +27,9 @@ 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: () => { @@ -46,10 +40,7 @@ export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { }, 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); From 9030f1d0a4cdb2c395a06a3a8615471e9e95f96e Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 15:30:11 -0700 Subject: [PATCH 08/13] docs(auth): update Google sign-in flow documentation and constants naming - Added a guideline for using uppercase and underscores for constant names, exemplified by `SIGNIN_INCREMENTAL`. - Updated documentation to reflect the new naming conventions for auth modes: `SIGNUP`, `SIGNIN_INCREMENTAL`, and `RECONNECT_REPAIR`. - Ensured consistency in the codebase by renaming references to auth modes in tests and service files. Co-authored-by: Tyler Dane --- AGENTS.md | 1 + docs/google-sync-and-websocket-flow.md | 8 +++--- .../google.auth.success.service.test.ts | 20 ++++---------- .../google/google.auth.success.service.ts | 26 +++++++++---------- packages/backend/src/sync/util/sync.util.ts | 2 +- 5 files changed, 24 insertions(+), 33 deletions(-) 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/google-sync-and-websocket-flow.md b/docs/google-sync-and-websocket-flow.md index 7bcfa373a..b78ed35f3 100644 --- a/docs/google-sync-and-websocket-flow.md +++ b/docs/google-sync-and-websocket-flow.md @@ -130,7 +130,7 @@ Revocation and reconnect are handled across auth, sync, websocket, and repositor - 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()`. +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 @@ -139,9 +139,9 @@ The backend determines auth mode based on server-side state, not frontend intent | 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()` | +| 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: The `googleAuthIntent` field from frontend is deprecated and no longer authoritative for routing. 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 b50bf80a3..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 @@ -87,7 +87,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: true, recipeUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -112,7 +111,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: true, recipeUserId: faker.database.mongodbObjectId(), loginMethodsLength: 1, - sessionUserId: null, }; await expect(handleGoogleAuth(success, authService)).rejects.toThrow( @@ -123,7 +121,7 @@ describe("handleGoogleAuth", () => { }); }); - describe("reconnect_repair path", () => { + 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(); @@ -143,7 +141,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: compassUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -177,7 +174,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: compassUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -211,7 +207,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: compassUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -239,7 +234,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: compassUserId, loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -250,7 +244,7 @@ describe("handleGoogleAuth", () => { }); }); - describe("signin_incremental path", () => { + 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); @@ -271,7 +265,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: faker.database.mongodbObjectId(), loginMethodsLength: 1, - sessionUserId: null, }; await handleGoogleAuth(success, authService); @@ -293,7 +286,7 @@ describe("handleGoogleAuth", () => { const authService = createMockAuthService(); - // Scenario 1: No user → signup + // Scenario 1: No user → SIGNUP mockFindCompassUserBy.mockResolvedValue(null); await handleGoogleAuth( { @@ -302,7 +295,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: true, recipeUserId: faker.database.mongodbObjectId(), loginMethodsLength: 1, - sessionUserId: null, }, authService, ); @@ -310,7 +302,7 @@ describe("handleGoogleAuth", () => { jest.clearAllMocks(); - // Scenario 2: User exists but no refresh token → reconnect_repair + // Scenario 2: User exists but no refresh token → RECONNECT_REPAIR const userNoToken = makeCompassUser({ hasRefreshToken: false }); mockFindCompassUserBy.mockResolvedValue(userNoToken); mockGetSync.mockResolvedValue({ google: { events: [] } }); @@ -322,7 +314,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: userNoToken._id.toString(), loginMethodsLength: 1, - sessionUserId: null, }, authService, ); @@ -330,7 +321,7 @@ describe("handleGoogleAuth", () => { jest.clearAllMocks(); - // Scenario 3: User exists with token and healthy sync → signin_incremental + // Scenario 3: User exists with token and healthy sync → SIGNIN_INCREMENTAL const healthyUser = makeCompassUser({ hasRefreshToken: true }); mockFindCompassUserBy.mockResolvedValue(healthyUser); mockGetSync.mockResolvedValue({ @@ -344,7 +335,6 @@ describe("handleGoogleAuth", () => { createdNewRecipeUser: false, recipeUserId: healthyUser._id.toString(), loginMethodsLength: 1, - sessionUserId: null, }, authService, ); 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 c076ed04b..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 @@ -16,11 +16,11 @@ export type GoogleSignInSuccess = { /** * 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) + * - 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 AuthMode = "SIGNUP" | "SIGNIN_INCREMENTAL" | "RECONNECT_REPAIR"; export type AuthDecision = { authMode: AuthMode; @@ -51,9 +51,9 @@ export interface GoogleSignInSuccessAuthService { * 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 + * - 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, @@ -64,7 +64,7 @@ async function determineAuthMode( if (!user) { return { - authMode: "signup", + authMode: "SIGNUP", compassUserId: null, hasStoredRefreshToken: false, hasHealthySync: false, @@ -82,7 +82,7 @@ async function determineAuthMode( // If missing refresh token OR unhealthy sync → needs repair if (!hasStoredRefreshToken || !hasHealthySync) { return { - authMode: "reconnect_repair", + authMode: "RECONNECT_REPAIR", compassUserId, hasStoredRefreshToken, hasHealthySync, @@ -91,7 +91,7 @@ async function determineAuthMode( } return { - authMode: "signin_incremental", + authMode: "SIGNIN_INCREMENTAL", compassUserId, hasStoredRefreshToken, hasHealthySync, @@ -120,7 +120,7 @@ export async function handleGoogleAuth( const decision = await determineAuthMode(googleUserId, createdNewRecipeUser); switch (decision.authMode) { - case "signup": { + case "SIGNUP": { const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; if (!isNewUser) { // Edge case: no Compass user found but SuperTokens says not new @@ -140,7 +140,7 @@ export async function handleGoogleAuth( return; } - case "reconnect_repair": { + case "RECONNECT_REPAIR": { // User exists but needs repair (missing refresh token or unhealthy sync) await authService.repairGoogleConnection( decision.compassUserId!, @@ -150,7 +150,7 @@ export async function handleGoogleAuth( return; } - case "signin_incremental": { + case "SIGNIN_INCREMENTAL": { // Healthy returning user - attempt incremental sync await authService.googleSignin(providerUser, oAuthTokens); return; diff --git a/packages/backend/src/sync/util/sync.util.ts b/packages/backend/src/sync/util/sync.util.ts index 4b43e8d3b..7f016549f 100644 --- a/packages/backend/src/sync/util/sync.util.ts +++ b/packages/backend/src/sync/util/sync.util.ts @@ -56,7 +56,7 @@ export const hasGoogleHeaders = (headers: object) => { * - 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). + * (RECONNECT_REPAIR) vs incremental sync (SIGNIN_INCREMENTAL). */ export const canDoIncrementalSync = (sync: Schema_Sync) => { const events = sync.google?.events; From 42fafdb27ed0e165201a95841d727c13ddb4e51b Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 16:17:56 -0700 Subject: [PATCH 09/13] refactor(auth): remove frontend intent from Google auth flow - Updated the Google authentication process to eliminate reliance on the `googleAuthIntent` from the frontend, making the server the authoritative source for auth mode determination. - Refactored related components and hooks to reflect this change, ensuring a cleaner and more consistent implementation. - Adjusted tests to align with the new logic, removing references to the deprecated intent and enhancing overall test coverage. Co-authored-by: Tyler Dane --- docs/google-sync-and-websocket-flow.md | 6 +- .../middleware/supertokens.middleware.ts | 6 -- .../middleware/supertokens.middleware.util.ts | 17 ------ .../auth/hooks/oauth/useConnectGoogle.test.ts | 50 +++++------------ .../src/auth/hooks/oauth/useConnectGoogle.ts | 10 +--- .../auth/hooks/oauth/useGoogleAuth.test.ts | 55 +++++-------------- .../web/src/auth/hooks/oauth/useGoogleAuth.ts | 8 +-- .../src/auth/session/user-metadata.util.ts | 6 +- .../toast/session-expired.toast.test.tsx | 4 +- .../utils/toast/session-expired.toast.tsx | 2 +- .../web/src/components/oauth/ouath.types.ts | 2 - 11 files changed, 42 insertions(+), 124 deletions(-) diff --git a/docs/google-sync-and-websocket-flow.md b/docs/google-sync-and-websocket-flow.md index b78ed35f3..93602b24b 100644 --- a/docs/google-sync-and-websocket-flow.md +++ b/docs/google-sync-and-websocket-flow.md @@ -135,7 +135,7 @@ Revocation and reconnect are handled across auth, sync, websocket, and repositor ### Auth Mode Classification -The backend determines auth mode based on server-side state, not frontend intent: +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 | | ----------------------------------------------------- | -------------------- | -------------------------- | @@ -143,7 +143,7 @@ The backend determines auth mode based on server-side state, not frontend intent | User exists + missing refresh token OR unhealthy sync | `RECONNECT_REPAIR` | `repairGoogleConnection()` | | User exists + valid refresh token + healthy sync | `SIGNIN_INCREMENTAL` | `googleSignin()` | -Note: The `googleAuthIntent` field from frontend is deprecated and no longer authoritative for routing. +Note: Frontend reconnect intent is no longer used for routing. The server is the source of truth for auth mode selection. Primary files: @@ -181,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/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index d27634602..812a295a3 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) { diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index 463f4d34f..7e08ae64e 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -19,23 +19,6 @@ export type CreateGoogleSignInResponse = | { status: Exclude } | GoogleThirdPartySignInUpSuccess; -/** - * @deprecated This function is transitional. Auth mode is now determined - * server-side in handleGoogleAuth() based on refresh token presence and - * sync health. The googleAuthIntent is no longer authoritative for routing. - * - * Kept temporarily for backward compatibility during transition period. - */ -export function getGoogleAuthIntent( - value: unknown, -): GoogleAuthIntent | undefined { - if (value === "connect" || value === "reconnect") { - return value; - } - - return undefined; -} - export function createGoogleSignInSuccess( response: CreateGoogleSignInResponse, ): GoogleSignInSuccess | null { diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 0e154fd25..6a45b3bf0 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…", ); @@ -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"); @@ -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", ); @@ -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", ); @@ -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(); @@ -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,7 +276,7 @@ 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 { @@ -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", () => { @@ -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..910cb7476 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -201,13 +201,10 @@ export const useConnectGoogle = () => { ); const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; const syncStatus = googleMetadata?.syncStatus ?? "none"; - const { login } = useGoogleAuth({ - googleAuthIntent: - connectionStatus === "reconnect_required" ? "reconnect" : undefined, - }); + 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 38b580dfe..62b8c6c6c 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -35,7 +35,6 @@ export function useGoogleAuth() { onStart: () => { dismissErrorToast(SESSION_EXPIRED_TOAST_ID); dispatch(startAuthenticating()); - dispatch(importGCalSlice.actions.setIsImportPending(true)); dispatch(importGCalSlice.actions.clearImportResults(undefined)); }, onSuccess: async (data) => { @@ -57,17 +56,14 @@ export function useGoogleAuth() { 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.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< From 4b24db25e3afa54bc53c6a90a7d10cb1c0aede60 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 16:26:57 -0700 Subject: [PATCH 10/13] refactor(types): improve interface formatting for consistency - Streamlined interface definitions across various files to enhance readability by aligning type extensions and removing unnecessary line breaks. - Updated interfaces in event, user, and component types to follow a consistent style, improving maintainability and clarity. --- packages/core/src/types/event.types.ts | 12 ++++++++---- packages/core/src/types/user.types.ts | 9 ++++----- packages/web/src/__tests__/__mocks__/mock.render.tsx | 3 +-- packages/web/src/common/types/api.types.ts | 5 +++-- .../components/AuthModal/components/AuthInput.tsx | 6 ++++-- packages/web/src/components/DND/Draggable.tsx | 9 ++++----- packages/web/src/components/IconButton/styled.ts | 3 +-- packages/web/src/components/Input/Input.tsx | 3 ++- packages/web/src/components/Textarea/types.ts | 4 +--- .../web/src/views/Forms/ActionsMenu/MenuItem.tsx | 3 +-- 10 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/core/src/types/event.types.ts b/packages/core/src/types/event.types.ts index 1ae9d5033..7e8756dbd 100644 --- a/packages/core/src/types/event.types.ts +++ b/packages/core/src/types/event.types.ts @@ -104,15 +104,19 @@ export type Schema_Event_Regular = Omit< "recurrence" | "gRecurringEventId" >; -export interface Schema_Event_Recur_Base - extends Omit { +export interface Schema_Event_Recur_Base extends Omit< + Schema_Event, + "recurrence" | "gRecurringEventId" +> { recurrence: { rule: string[]; // No eventId since this is the base recurring event }; } -export interface Schema_Event_Recur_Instance - extends Omit { +export interface Schema_Event_Recur_Instance extends Omit< + Schema_Event, + "recurrence" +> { recurrence: { eventId: string; // No rule since this is an instance of the recurring event }; diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index ef43566dc..19e4fc025 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -36,11 +36,10 @@ export interface UserMetadata extends SupertokensUserMetadata.JSONObject { }; } -export interface UserProfile - extends Pick< - WithCompassId, - "firstName" | "lastName" | "name" | "email" | "locale" - > { +export interface UserProfile extends Pick< + WithCompassId, + "firstName" | "lastName" | "name" | "email" | "locale" +> { picture: string; userId: string; } diff --git a/packages/web/src/__tests__/__mocks__/mock.render.tsx b/packages/web/src/__tests__/__mocks__/mock.render.tsx index ed2f8b033..607fbf969 100644 --- a/packages/web/src/__tests__/__mocks__/mock.render.tsx +++ b/packages/web/src/__tests__/__mocks__/mock.render.tsx @@ -29,8 +29,7 @@ interface CustomRenderOptions extends RenderOptions { } interface CustomRenderHookOptions - extends CustomRenderOptions, - Omit, "wrapper"> {} + extends CustomRenderOptions, Omit, "wrapper"> {} const TestProviders = (props?: { router?: RouterProviderProps["router"]; diff --git a/packages/web/src/common/types/api.types.ts b/packages/web/src/common/types/api.types.ts index 629d67065..dc47b7ba2 100644 --- a/packages/web/src/common/types/api.types.ts +++ b/packages/web/src/common/types/api.types.ts @@ -15,8 +15,9 @@ export interface Filters_Pagination { export type Options_FilterSort = Filters_Pagination & Options_Sort; -export interface Response_HttpPaginatedSuccess - extends Filters_Pagination { +export interface Response_HttpPaginatedSuccess< + Data, +> extends Filters_Pagination { data: Data; count: number; [key: string]: unknown | undefined; diff --git a/packages/web/src/components/AuthModal/components/AuthInput.tsx b/packages/web/src/components/AuthModal/components/AuthInput.tsx index c0cbe8382..b3350e413 100644 --- a/packages/web/src/components/AuthModal/components/AuthInput.tsx +++ b/packages/web/src/components/AuthModal/components/AuthInput.tsx @@ -1,8 +1,10 @@ import clsx from "clsx"; import { type InputHTMLAttributes, forwardRef, useId } from "react"; -interface AuthInputProps - extends Omit, "className"> { +interface AuthInputProps extends Omit< + InputHTMLAttributes, + "className" +> { /** Label text displayed above the input. Omit for placeholder-only style. */ label?: string; /** Accessible name when label is hidden (required when label is omitted) */ diff --git a/packages/web/src/components/DND/Draggable.tsx b/packages/web/src/components/DND/Draggable.tsx index a3d39bf4f..a3aba839b 100644 --- a/packages/web/src/components/DND/Draggable.tsx +++ b/packages/web/src/components/DND/Draggable.tsx @@ -25,11 +25,10 @@ export interface DraggableDNDData { view: "day" | "week" | "now"; } -export interface DNDChildProps - extends Pick< - ReturnType, - "over" | "listeners" | "isDragging" - > { +export interface DNDChildProps extends Pick< + ReturnType, + "over" | "listeners" | "isDragging" +> { id: UniqueIdentifier; setDisabled?: (disabled: boolean) => void; } diff --git a/packages/web/src/components/IconButton/styled.ts b/packages/web/src/components/IconButton/styled.ts index 95163b677..d6e3703b1 100644 --- a/packages/web/src/components/IconButton/styled.ts +++ b/packages/web/src/components/IconButton/styled.ts @@ -8,8 +8,7 @@ const sizeMap: Record = { large: 34, }; -export interface IconButtonProps - extends React.ButtonHTMLAttributes { +export interface IconButtonProps extends React.ButtonHTMLAttributes { size?: IconButtonSize; } diff --git a/packages/web/src/components/Input/Input.tsx b/packages/web/src/components/Input/Input.tsx index 3918148f5..6e933a4b3 100644 --- a/packages/web/src/components/Input/Input.tsx +++ b/packages/web/src/components/Input/Input.tsx @@ -12,7 +12,8 @@ import { Focusable } from "../Focusable/Focusable"; import { StyledInput, type Props as StyledProps } from "./styled"; export interface Props - extends ClassNamedComponent, + extends + ClassNamedComponent, UnderlinedInput, StyledProps, HTMLAttributes { diff --git a/packages/web/src/components/Textarea/types.ts b/packages/web/src/components/Textarea/types.ts index c48e3a315..7b8d4dc29 100644 --- a/packages/web/src/components/Textarea/types.ts +++ b/packages/web/src/components/Textarea/types.ts @@ -5,8 +5,6 @@ import { } from "@web/common/types/component.types"; export interface TextareaProps - extends UnderlinedInput, - ClassNamedComponent, - TextareaAutosizeProps { + extends UnderlinedInput, ClassNamedComponent, TextareaAutosizeProps { heightFitsContent?: boolean; } diff --git a/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx b/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx index 2d5cff4b3..e095af2a8 100644 --- a/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx +++ b/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx @@ -10,8 +10,7 @@ import { import { useMenuContext } from "./ActionsMenu"; import { StyledMenuItem } from "./styled"; -export interface MenuItemProps - extends React.ButtonHTMLAttributes { +export interface MenuItemProps extends React.ButtonHTMLAttributes { /** * Content to render inside the delayed tooltip. If omitted, the tooltip is disabled. */ From 0a8023e353306b97e228280d7bab41dde3cb40a0 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 16:41:43 -0700 Subject: [PATCH 11/13] test(socket): enhance useGcalSync tests for connection status handling - Added a test to ensure that an import request is not automatically triggered when the Google connection status is "reconnect_required". - Updated the logic in the useGcalSync hook to only auto-request an import when the connection status is "connected". - Improved test coverage for various connection scenarios, ensuring robust handling of metadata updates. --- .../web/src/socket/hooks/useGcalSync.test.ts | 23 ++++++++++++++++++- packages/web/src/socket/hooks/useGcalSync.ts | 4 +++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index df24eee4e..65453f084 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -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) => { diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 18e452bc8..41ca656ea 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -81,8 +81,10 @@ export const useGcalSync = () => { const onMetadataFetch = useCallback( (metadata: UserMetadata) => { const importStatus = metadata.sync?.importGCal; + const connectionStatus = metadata.google?.connectionStatus; const isBackendImporting = importStatus === "importing"; - const shouldAutoImport = importStatus === "restart"; + const shouldAutoImport = + importStatus === "restart" && connectionStatus === "connected"; dispatch(userMetadataSlice.actions.set(metadata)); From c4e636045ae42f8b0eeb554b9962103aca1bdba1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 16:48:55 -0700 Subject: [PATCH 12/13] refactor(sync): standardize sync status constants to uppercase - Updated all instances of sync status values in the codebase to use uppercase naming conventions: "IMPORTING", "ERRORED", "COMPLETED", and "RESTART". - Adjusted related tests and documentation to reflect these changes, ensuring consistency across the application. - Improved clarity and maintainability of the sync logic by adhering to a unified naming standard. --- docs/api-documentation.md | 4 +- docs/google-sync-and-websocket-flow.md | 6 +-- .../services/compass.auth.service.test.ts | 4 +- .../src/auth/services/compass.auth.service.ts | 6 +-- .../middleware/supertokens.middleware.ts | 2 +- .../websocket/websocket.server.test.ts | 2 +- .../sync/controllers/sync.controller.test.ts | 24 +++++------ .../src/sync/controllers/sync.controller.ts | 2 +- .../backend/src/sync/services/sync.service.ts | 12 +++--- .../services/user-metadata.service.test.ts | 28 ++++++------- .../user/services/user-metadata.service.ts | 16 ++++---- .../src/user/services/user.service.test.ts | 34 ++++++++-------- .../backend/src/user/services/user.service.ts | 16 ++++---- packages/core/src/types/user.types.ts | 4 +- packages/core/src/types/websocket.types.ts | 4 +- packages/core/src/util/event/event.util.ts | 16 ++++---- .../auth/hooks/oauth/useConnectGoogle.test.ts | 14 +++---- .../src/auth/hooks/oauth/useConnectGoogle.ts | 6 +-- .../auth/session/user-metadata.util.test.ts | 2 +- .../web/src/socket/hooks/useGcalSync.test.ts | 40 +++++++++---------- packages/web/src/socket/hooks/useGcalSync.ts | 14 +++---- .../SocketProvider.interaction.test.tsx | 18 ++++----- .../socket/provider/SocketProvider.test.tsx | 4 +- .../SidebarIconRow/SidebarIconRow.test.tsx | 10 ++--- .../src/views/CmdPalette/CmdPalette.test.tsx | 4 +- .../Day/components/DayCmdPalette.test.tsx | 8 ++-- .../Now/components/NowCmdPalette.test.tsx | 8 ++-- 27 files changed, 154 insertions(+), 154 deletions(-) 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 93602b24b..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 @@ -161,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; 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 14ce90c24..7e3999fc2 100644 --- a/packages/backend/src/auth/services/compass.auth.service.test.ts +++ b/packages/backend/src/auth/services/compass.auth.service.test.ts @@ -53,8 +53,8 @@ describe("CompassAuthService", () => { expect(updatedUser?.google?.gRefreshToken).toBe( oAuthTokens.refresh_token, ); - expect(metadata.sync?.importGCal).toBe("restart"); - expect(metadata.sync?.incrementalGCalSync).toBe("restart"); + expect(metadata.sync?.importGCal).toBe("RESTART"); + expect(metadata.sync?.incrementalGCalSync).toBe("RESTART"); 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 be294286a..11c537186 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -74,7 +74,7 @@ class CompassAuthService { userId, data: { skipOnboarding: false, - sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, }, }); @@ -129,7 +129,7 @@ class CompassAuthService { await userMetadataService.updateUserMetadata({ userId: cUserId, data: { - sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" }, }, }); @@ -184,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/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index 812a295a3..3e5a37465 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -169,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/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/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/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 6a45b3bf0..2b4c289ee 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -85,7 +85,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -119,7 +119,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "connected", - syncStatus: "healthy", + syncStatus: "HEALTHY", }; } @@ -154,7 +154,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -192,7 +192,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "REPAIRING", }; } @@ -225,7 +225,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "connected", - syncStatus: "attention", + syncStatus: "ATTENTION", }; } @@ -281,7 +281,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "not_connected", - syncStatus: "none", + syncStatus: "NONE", }; } @@ -313,7 +313,7 @@ describe("useConnectGoogle", () => { if (selector === selectGoogleMetadata) { return { connectionStatus: "reconnect_required", - syncStatus: "none", + syncStatus: "NONE", }; } diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 910cb7476..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,7 +200,7 @@ export const useConnectGoogle = () => { ) => RootState["sync"]["importGCal"], ); const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; - const syncStatus = googleMetadata?.syncStatus ?? "none"; + const syncStatus = googleMetadata?.syncStatus ?? "NONE"; const { login } = useGoogleAuth(); const onOpenGoogleAuth = useCallback(() => { 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/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 65453f084..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), @@ -222,7 +222,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); metadataHandler?.({ - sync: { importGCal: "restart" }, + sync: { importGCal: "RESTART" }, google: { connectionStatus: "connected" }, }); @@ -242,7 +242,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); metadataHandler?.({ - sync: { importGCal: "restart" }, + sync: { importGCal: "RESTART" }, google: { connectionStatus: "reconnect_required" }, }); @@ -260,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(); @@ -306,7 +306,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); importEndHandler?.({ - status: "completed", + status: "COMPLETED", eventsCount: 10, calendarsCount: 2, }); @@ -340,7 +340,7 @@ describe("useGcalSync", () => { renderHook(() => useGcalSync()); - importEndHandler?.({ status: "completed", eventsCount: 10 }); + importEndHandler?.({ status: "COMPLETED", eventsCount: 10 }); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -372,7 +372,7 @@ describe("useGcalSync", () => { // Event arrives - should process correctly with ref pattern importEndHandler?.({ - status: "completed", + status: "COMPLETED", eventsCount: 10, calendarsCount: 2, }); @@ -402,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", }); @@ -457,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, }; @@ -502,7 +502,7 @@ describe("useGcalSync", () => { // Import completes successfully handlers[IMPORT_GCAL_END]({ - status: "completed", + status: "COMPLETED", eventsCount: 100, calendarsCount: 5, }); @@ -529,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, }); @@ -566,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( @@ -593,7 +593,7 @@ describe("useGcalSync", () => { mockDispatch.mockClear(); handlers[IMPORT_GCAL_END]({ - status: "completed", + status: "COMPLETED", eventsCount: 50, calendarsCount: 4, }); @@ -625,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 41ca656ea..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, @@ -82,16 +82,16 @@ export const useGcalSync = () => { (metadata: UserMetadata) => { const importStatus = metadata.sync?.importGCal; const connectionStatus = metadata.google?.connectionStatus; - const isBackendImporting = importStatus === "importing"; + const isBackendImporting = importStatus === "IMPORTING"; const shouldAutoImport = - importStatus === "restart" && connectionStatus === "connected"; + 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( @@ -99,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", }, }, }, From 90d762d0b78eb673a4176c74af214c162cfa6160 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 15 Mar 2026 16:55:32 -0700 Subject: [PATCH 13/13] feat(tests): configure Playwright to use 2 workers and improve event form submission - Updated Playwright configuration to utilize 2 workers for parallel test execution, enhancing performance. - Modified event form submission logic to use `locator.press()` for sending key events, ensuring focus remains on the input field during CI tests. - This change addresses potential issues with form submission visibility and reliability in automated testing environments. --- e2e/utils/event-test-utils.ts | 5 +++-- playwright.config.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) 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/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, },