Skip to content

Unify data when users connect their calendar mixed auth (pw and oauth) #1556

Description

@tyler-dane

Context

When a user signs up with email/password, creates events ("Foo"), then connects Google Calendar:

  • Expected: Foo persists in Compass, Foo is pushed to Google Calendar, Google events are pulled in
  • Actual: Foo disappears — the user sees only their Google Calendar events

Root cause (the disappearing events): manuallyCreateOrUpdateUser in the SuperTokens middleware looks up the existing user by { "google.googleId": googleUserId }. An email/password user has no googleId yet, so it returns null and a new ObjectId is generated. This new ID becomes the session's userId. googleSignup then calls upsertUserFromAuth({ userId: newId }), which finds no user with that ID and creates a new MongoDB user. The original email/password user (with "Foo") is now a ghost — the session points to a different user entirely.

Secondary goal (mirroring the IndexedDB pattern): Just as syncLocalEventsToCloud pushes IndexedDB events to Compass during useCompleteAuthentication, we want to push existing COMPASS events to Google Calendar when Google is connected for the first time. Infrastructure already exists: _createGcal, MapEvent.toGcal(), gcalService.createEvent().


Implementation Plan

Step 1 — Fix the root cause: preserve existing user ID

File: packages/backend/src/common/middleware/supertokens.middleware.ts

In manuallyCreateOrUpdateUser, change the MongoDB lookup to include an email fallback:

ts
// Before
{ "google.googleId": input.thirdPartyUserId }

// After
{ $or: [{ "google.googleId": input.thirdPartyUserId }, { email: input.email }] }

This ensures an existing email/password user's _id is reused as the SuperTokens recipe user ID, so the session stays tied to the correct user.


Step 2 — Add CONNECT_GOOGLE auth mode

Files:

  • packages/backend/src/auth/services/google/google.auth.types.ts — add "CONNECT_GOOGLE" to AuthMode
  • packages/backend/src/auth/services/google/util/google.auth.util.ts — update determineGoogleAuthMode

determineGoogleAuthMode currently only receives (googleUserId, createdNewRecipeUser). Add email as a third param. When no user is found by googleId, check by email:

ts
export async function determineGoogleAuthMode(
  googleUserId: string,
  email: string | undefined,
  createdNewRecipeUser: boolean,
): Promise<AuthDecision>

Logic addition (after the "not found by googleId" check):

ts
if (!userByGoogleId) {
  const userByEmail = email ? await findCompassUserBy("email", email) : null;
  if (userByEmail) {
    return {
      authMode: "CONNECT_GOOGLE",
      compassUserId: userByEmail._id.toString(),
      ...
    };
  }
  return { authMode: "SIGNUP", ... };
}

Update the call site in handleGoogleAuth to pass providerUser.email.


Step 3 — Add connectGoogleToExistingUser handler

File: packages/backend/src/auth/services/google/google.auth.service.ts

Add a new case "CONNECT_GOOGLE" in handleGoogleAuth that calls a new method:

ts
async connectGoogleToExistingUser(
  compassUserId: string,
  gUser: TokenPayload,
  oAuthTokens: Pick<Credentials, "refresh_token" | "access_token">,
)

This method:

  1. Calls userService.upsertUserFromAuth({ userId: compassUserId, google: { googleId, picture, gRefreshToken } })
  2. Sets sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" } metadata
  3. Calls this.restartGoogleCalendarSyncInBackground(compassUserId) (pull Google → Compass)
  4. Returns { cUserId: compassUserId }

Note: requires a refresh token — enforce with the same guard as SIGNUP mode.


Step 4 — Backend endpoint to push COMPASS events to Google

Files:

  • packages/backend/src/sync/services/sync.service.ts — new pushCompassEventsToGoogle(userId) method
  • packages/backend/src/sync/sync.routes.config.ts — new POST /api/sync/push-compass-events route
  • packages/backend/src/sync/controllers/sync.controller.ts — new controller method

pushCompassEventsToGoogle(userId):

  1. Fetch all COMPASS-origin events for the user without a gEventId (these are Compass-native events not yet in Google Calendar)
  2. Get the gcal client: getGcalClient(userId) (uses stored refresh token)
  3. For each event, call _createGcal(userId, event) — already exists in event.service.ts
  4. Update each event in MongoDB with the returned gEventId from Google
  5. Return { pushedCount: number }

Once events have gEventId set, the subsequent full Google Calendar import (triggered by restartGoogleCalendarSync) will match them by gEventId and update in place — no duplicates.


Step 5 — Frontend: mirror the IndexedDB → Compass pattern

Files:

  • packages/web/src/auth/google/google.auth.util.ts — add pushCompassEvents() function
  • packages/web/src/common/apis/sync.api.ts — add pushCompassEvents() API call
  • packages/web/src/auth/hooks/useCompleteAuthentication.ts — call pushCompassEvents() after syncLocalEvents()

Pattern mirrors syncLocalEvents exactly:

ts
// google.auth.util.ts
export async function pushCompassEvents(): Promise<{ pushedCount: number; success: boolean; error?: Error }> {
  try {
    const result = await SyncApi.pushCompassEvents();
    return { pushedCount: result.pushedCount, success: true };
  } catch (error) {
    return { pushedCount: 0, success: false, error: error as Error };
  }
}

In useCompleteAuthentication, after syncLocalEvents():

ts
const pushResult = await pushCompassEvents();
if (!pushResult.success) {
  // Non-critical — events still exist in Compass, just not in Google Calendar yet
  console.error(pushResult.error);
}
// triggerFetch already dispatched — will show updated events with gEventId

No toast needed on failure (events are preserved in Compass; pushing to Google Calendar can be silent).


Critical Files

File | Change -- | -- packages/backend/src/common/middleware/supertokens.middleware.ts | Email fallback in manuallyCreateOrUpdateUser MongoDB query packages/backend/src/auth/services/google/google.auth.types.ts | Add "CONNECT_GOOGLE" to AuthMode packages/backend/src/auth/services/google/util/google.auth.util.ts | Add email param + CONNECT_GOOGLE detection in determineGoogleAuthMode packages/backend/src/auth/services/google/google.auth.service.ts | Add connectGoogleToExistingUser + CONNECT_GOOGLE case in handleGoogleAuth packages/backend/src/sync/services/sync.service.ts | New pushCompassEventsToGoogle(userId) method packages/backend/src/sync/sync.routes.config.ts | New POST /push-compass-events route packages/backend/src/sync/controllers/sync.controller.ts | New controller handler packages/web/src/auth/google/google.auth.util.ts | Add pushCompassEvents() wrapper packages/web/src/common/apis/sync.api.ts | Add pushCompassEvents() API call packages/web/src/auth/hooks/useCompleteAuthentication.ts | Call pushCompassEvents() after syncLocalEvents()

Reusable Infrastructure (do not rewrite)

  • _createGcal(userId, event)packages/backend/src/event/services/event.service.ts
  • MapEvent.toGcal(event)packages/core/src/mappers/map.event.ts
  • getGcalClient(userId)packages/backend/src/auth/services/google/clients/google.calendar.client.ts
  • findCompassUserBy(key, value)packages/backend/src/user/queries/user.queries.ts

Verification

  1. Root cause fix: Sign up with email, create event "Foo", connect Google → "Foo" should still appear in Compass after connecting
  2. Push to Google: After connecting, open Google Calendar → "Foo" should appear as a new event
  3. No duplicates: "Foo" should appear exactly once in Compass after the full Google Calendar import runs
  4. Existing Google users unaffected: Sign up with Google → sign in again → SIGNIN_INCREMENTAL mode still works normally
  5. Reconnect still works: A Google user whose token expired goes through RECONNECT_REPAIR, not CONNECT_GOOGLE
  6. New Google signups unaffected: Brand new user signing up with Google → SIGNUP mode, no COMPASS events to push (no-op)
  7. Tests: Update google.auth.service.test.ts, google.auth.util.ts tests for new mode; add test for connectGoogleToExistingUser

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions