From b58c5f9287fb5f4a9ccffff0265a2266aa813455 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 17:52:36 -0800 Subject: [PATCH 01/15] feat(auth): implement email/password authentication and Google account linking - Added email/password sign-up and sign-in functionality using Supertokens, allowing users to authenticate without Google. - Implemented a "Connect Google Calendar" feature for users who signed up with email/password, enabling account linking and synchronization of Compass-only events to Google. - Updated the SessionExpiredToast to provide a generic message for session expiration, directing users to sign in via AuthModal. - Enhanced tests for new authentication flows and ensured consistent naming for authentication state. - Improved user experience by allowing seamless sign-in from any device using either authentication method. --- PW-PLAN-1.md | 143 ++++++++++++++++++++++++++++++++++++++++ PW-PLAN-2.md | 163 ++++++++++++++++++++++++++++++++++++++++++++++ PW-PLAN-3.md | 133 +++++++++++++++++++++++++++++++++++++ PW-PLAN-4.md | 134 +++++++++++++++++++++++++++++++++++++ PW-PLAN-5.md | 128 ++++++++++++++++++++++++++++++++++++ PW-PLAN-MASTER.md | 53 +++++++++++++++ 6 files changed, 754 insertions(+) create mode 100644 PW-PLAN-1.md create mode 100644 PW-PLAN-2.md create mode 100644 PW-PLAN-3.md create mode 100644 PW-PLAN-4.md create mode 100644 PW-PLAN-5.md create mode 100644 PW-PLAN-MASTER.md diff --git a/PW-PLAN-1.md b/PW-PLAN-1.md new file mode 100644 index 000000000..cbcbf715e --- /dev/null +++ b/PW-PLAN-1.md @@ -0,0 +1,143 @@ +# PR 1: User Schema & GCal Guards + +**Branch**: `feature/add-user-schema-guards` +**Goal**: Make the user model and event/sync logic tolerant of users without Google. No new auth flows; no behavior change for existing Google users. + +## Success Criteria + +- All existing tests pass. +- Google users continue to work exactly as before. +- Code paths that call `getGcalClient` or touch `user.google` safely handle users without Google. +- No user-facing changes. + +## Changes + +### 1. User schema – make `google` optional + +**File**: `packages/core/src/types/user.types.ts` + +- Change `google: { ... }` to `google?: { ... }` in `Schema_User`. +- Update `UserProfile` to handle optional `picture`: use `picture?: string` or derive from `google?.picture` with fallback. + +```ts +// Before +google: { + googleId: string; + picture: string; + gRefreshToken: string; +}; + +// After +google?: { + googleId: string; + picture: string; + gRefreshToken: string; +}; +``` + +### 2. Add `UserError.GoogleNotConnected` and type guard + +**File**: `packages/backend/src/common/errors/user/user.errors.ts` + +- Add `GoogleNotConnected` to the interface and export: + +```ts +GoogleNotConnected: { + description: "User has not connected Google Calendar", + status: Status.BAD_REQUEST, + isOperational: true, +}, +``` + +- Add type guard (in same file or `packages/backend/src/common/errors/user/user.error.utils.ts`): + +```ts +export const isGoogleNotConnectedError = (e: unknown): e is BaseError => + e instanceof BaseError && + e.description === UserError.GoogleNotConnected.description; +``` + +### 3. Update `getGcalClient` and `getGAuthClientForUser` for users without Google + +**File**: `packages/backend/src/auth/services/google.auth.service.ts` + +- In `getGcalClient`: after fetching user, if `!user?.google?.gRefreshToken`, throw `error(UserError.GoogleNotConnected, "User has not connected Google Calendar")` (not the generic GaxiosError). Keep existing behavior when user is not found (still throw `GaxiosError` for session invalidation). +- In `getGAuthClientForUser`: fix `_user.google.gRefreshToken` access (line 45) to handle optional `google` when refetching by userId — throw `error(UserError.GoogleNotConnected, "User has not connected Google Calendar")` if `!_user?.google?.gRefreshToken` instead of accessing undefined. + +Centralizing in `getGcalClient` means one DB fetch and one place to check; no separate `hasGoogleConnected` helper. + +### 4. Event service – catch and handle `GoogleNotConnected` + +**File**: `packages/backend/src/event/services/event.service.ts` + +- In `_getGcal`, `_createGcal`, `_updateGcal`, `_deleteGcal`: wrap `getGcalClient` in try/catch; if `isGoogleNotConnectedError(e)`, return `null` (create/update/get) or no-op (delete). Re-throw other errors. +- Update return types where needed (`GEvent | null` for create/update/get). + +### 5. Parser – treat `null` as Compass-only success + +**File**: `packages/backend/src/event/classes/compass.event.parser.ts` + +- In `createEvent`, `updateEvent`, `updateSeries`, `deleteEvent`, `deleteSeries`: when the event-service GCal method returns `null` (no Google), return `[operationSummary]` — the Compass operation succeeded, GCal was skipped. + +### 6. Other `getGcalClient` call sites – catch and handle + +| File | Context | Action when `isGoogleNotConnectedError` | +| ----------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `packages/backend/src/user/services/user.service.ts` | `startGoogleCalendarSync` | Catch; return `{ eventsCount: 0, calendarsCount: 0 }` | +| `packages/backend/src/user/services/user.service.ts` | `restartGoogleCalendarSync` | Catch; return early / no-op | +| `packages/backend/src/sync/services/sync.service.ts` | `handleGCalNotification`, `importIncremental` | Catch; log and return / skip | +| `packages/backend/src/sync/services/import/sync.import.ts` | `createSyncImport` | When `id` is string, catch and re-throw or return no-op import | +| `packages/backend/src/sync/services/maintain/sync.maintenance.ts` | `prune`, `refreshWatch` | Catch; skip user / early return | +| `packages/backend/src/sync/controllers/sync.debug.controller.ts` | Debug endpoint | Let error propagate — Express handler returns 400 via `UserError.GoogleNotConnected` | +| `packages/backend/src/calendar/services/calendar.service.ts` | Calendar init | Only used after Google OAuth; add catch if called from a path that could run for non-Google users. | + +Express error handler already maps `BaseError` to `res.status(e.statusCode).send(e)`; no change needed. + +Test files and migration scripts can continue to use users with Google; no changes required unless they assert on error behavior. + +### 7. Update user profile / getProfile for optional `google` + +**File**: `packages/backend/src/user/services/user.service.ts` + +- In `getProfile`: `picture` comes from `user.google.picture`. Change to `user.google?.picture ?? ""` or a placeholder (e.g. empty string, or a default avatar URL). +- Ensure projection includes `google.picture` when present. + +### 8. Update `map.user.ts` usage + +**File**: `packages/core/src/mappers/map.user.ts` + +- `mapUserToCompass` is only used for Google users; no change. +- Any other mapper that assumes `user.google` must use optional chaining. + +## Test Plan + +1. **Unit tests** + + ```bash + yarn test:core + yarn test:backend + yarn test:web + ``` + +2. **Regression**: + - Create a test user with Google (use existing fixtures). + - Create/update/delete events; verify GCal sync still works. + - Run sync import; verify no errors. + +3. **New behavior** (mock or create a user without `google`): + - `getGcalClient` throws `UserError.GoogleNotConnected` for user without `google`. + - Event create/update/delete for that user does not call GCal; events are stored in MongoDB only. + +## Validation Commands + +```bash +yarn install --frozen-lockfile --network-timeout 300000 +yarn test:core +yarn test:web +yarn test:backend +yarn prettier . --write +``` + +## Rollback + +Revert the PR. All changes are additive (optional fields, guards). No migrations. diff --git a/PW-PLAN-2.md b/PW-PLAN-2.md new file mode 100644 index 000000000..0929e0123 --- /dev/null +++ b/PW-PLAN-2.md @@ -0,0 +1,163 @@ +# PR 2: Email/Password Auth + +**Branch**: `feature/add-email-password-auth` +**Goal**: Enable sign-up and sign-in with email and password. Wire AuthModal to the new backend. + +**Depends on**: PR 1 (user schema and guards). + +## Success Criteria + +- Users can sign up with email, name, and password. +- Users can sign in with email and password. +- Sessions work for email/password users (profile, events, etc.). +- Google OAuth continues to work as before. +- AuthModal sign-up and login forms call the new API. +- All tests pass. + +## Changes + +### 1. Add Supertokens EmailPassword recipe + +**File**: `packages/backend/src/common/middleware/supertokens.middleware.ts` + +- Replace `ThirdParty` with `ThirdPartyEmailPassword` from `supertokens-node/recipe/thirdpartyemailpassword`. +- Configure both Google (existing) and EmailPassword. +- Keep existing `signInUp` override for Google; add overrides for `emailPasswordSignUp` and `emailPasswordSignIn` if custom logic is needed. +- Ensure Supertokens exposes the new routes (signup, signin for email/password). Refer to [ThirdPartyEmailPassword docs](https://supertokens.com/docs/thirdpartyemailpassword/introduction). + +**Alternative**: Add `EmailPassword` as a separate recipe alongside `ThirdParty` if ThirdPartyEmailPassword doesn’t fit. The important part is having email/password sign-up and sign-in endpoints. + +### 2. Backend: email/password sign-up flow + +**File**: `packages/backend/src/auth/services/compass.auth.service.ts` + +Add `emailPasswordSignup`: + +1. Supertokens creates the EmailPassword user (handled by recipe). +2. Get the Supertokens user ID from the sign-up response. +3. Create Compass user in MongoDB: `email`, `firstName`, `lastName`, `name`, `locale` from request body. **No `google` field.** +4. Call `userService.initUserDataEmailPassword` (new method) to create user, default priorities, and metadata. +5. Create session via `Session.createNewSessionWithoutRequestResponse` (or use Supertokens default session creation). +6. Return session tokens to client. + +**File**: `packages/backend/src/user/services/user.service.ts` + +Add `initUserDataEmailPassword`: + +- Accept `{ email, firstName, lastName, name?, locale? }`. +- Create user document without `google`. +- Create default priorities. +- Return the created user. + +### 3. Backend: email/password sign-in flow + +**File**: `packages/backend/src/auth/services/compass.auth.service.ts` + +Add `emailPasswordSignin`: + +1. Validate credentials via Supertokens EmailPassword recipe. +2. Look up Compass user by email. +3. Create session. +4. Return session tokens. + +If the user doesn’t exist in MongoDB (legacy or edge case), create a minimal user or return an error. + +### 4. Backend: auth routes + +**File**: `packages/backend/src/auth/` (routes or controllers) + +- Add `POST /auth/signup` (or use Supertokens FDI endpoint) for email/password sign-up. +- Add `POST /auth/signin` (or use Supertokens FDI endpoint) for email/password sign-in. + +Supertokens may expose these automatically; if so, add a custom middleware/override to create the Compass user and metadata on sign-up. + +### 5. Frontend: Auth API + +**File**: `packages/web/src/common/apis/auth.api.ts` + +Add: + +```ts +async signUpEmailPassword(data: { email: string; password: string; firstName: string; lastName: string; name?: string }): Promise +async signInEmailPassword(data: { email: string; password: string }): Promise +``` + +These call the new backend endpoints and return session/redirect info. + +### 6. Frontend: wire AuthModal + +**File**: `packages/web/src/components/AuthModal/AuthModal.tsx` + +- `handleSignUp`: call `AuthApi.signUpEmailPassword` with form data. On success: `markUserAsAuthenticated()`, `setAuthenticated(true)`, dispatch auth success, close modal. On error: show error. +- `handleLogin`: call `AuthApi.signInEmailPassword`. Same success/error handling. +- `handleForgotPassword`: TODO for a later PR; show a stub or disable. + +### 7. Profile for users without Google + +**File**: `packages/backend/src/user/services/user.service.ts` + +- `getProfile` already handles optional `google` (from PR 1). Ensure `picture` fallback: use empty string or a placeholder (e.g. initials-based URL or `/avatar-placeholder.svg`). + +**File**: `packages/core/src/types/user.types.ts` + +- `UserProfile.picture` should allow empty string if no Google picture. + +### 8. Auth state and repository selection + +**File**: `packages/web/src/common/utils/storage/auth-state.util.ts` + +- `markUserAsAuthenticated()`: already updates `isGoogleAuthenticated` to true. Keep as-is for now (semantic: “user has authenticated”, even if via email/pw). PR 5 can rename. +- `hasUserEverAuthenticated()`: used to decide LocalEventRepository vs RemoteEventRepository. Email/password sign-in should also call `markUserAsAuthenticated()` so repository selection is correct. + +### 9. Sign-up / sign-in success flow + +After successful email/password auth: + +1. Call `markUserAsAuthenticated()`. +2. Call `session` SDK to store session (Supertokens frontend handles this if using their React SDK). +3. Dispatch auth success (Redux). +4. Trigger event fetch (e.g. `triggerFetch()`). +5. Optionally run `syncLocalEventsToCloud()` if the user had local events before signing up (same as Google flow). + +**File**: `packages/web/src/common/utils/auth/` (create if needed) + +- Add `authenticateEmailPassword` similar to `authenticate` for Google, or extend `authenticate` to accept type and data. + +### 10. Session init for email/password + +**File**: `packages/web/src/auth/session/SessionProvider.tsx` + +- Supertokens Session recipe treats all sessions the same. No change needed if using the same session recipe for both auth methods. +- Ensure `session.doesSessionExist()` returns true after email/password sign-in. + +## Test Plan + +1. **Unit tests** + - `compass.auth.service`: test `emailPasswordSignup` and `emailPasswordSignin` with mocks. + - `user.service`: test `initUserDataEmailPassword`. + - AuthModal: test that submit calls the correct API. + +2. **Integration** + - Sign up with email/password → session exists → profile loads → can create events. + - Sign in with email/password → session exists → events load. + - Sign out → session cleared. + - Google OAuth still works. + +3. **Manual** + - Open AuthModal → Sign up with new email → verify account created, can create events. + - Sign out → Sign in with same email/password → verify events visible. + +## Validation Commands + +```bash +yarn install --frozen-lockfile --network-timeout 300000 +yarn test:core +yarn test:web +yarn test:backend +yarn dev:web +# Manual: sign up, sign in, create event, sign out, sign in again +``` + +## Rollback + +Revert the PR. If users were created via email/password, they will still exist in the DB; they just won’t be able to sign in until this feature is re-enabled. diff --git a/PW-PLAN-3.md b/PW-PLAN-3.md new file mode 100644 index 000000000..ab1cfce6b --- /dev/null +++ b/PW-PLAN-3.md @@ -0,0 +1,133 @@ +# PR 3: Session Expired Toast + +**Branch**: `feature/update-session-expired-toast` +**Goal**: Replace Google-specific "reconnect" message with a generic "Session expired" toast that opens AuthModal (supports both email/password and Google). + +**Depends on**: PR 2 recommended (so both email/password and Google work in AuthModal). Can merge before PR 2—toast would open AuthModal, but only Google sign-in would work until PR 2. + +## Success Criteria + +- On 401, toast shows "Session expired. Please sign in again." +- Button label: "Sign in" (not "Reconnect Google Calendar"). +- Clicking the button opens AuthModal on the login view. +- User can sign in with email/password or Google. +- All tests pass. + +## Changes + +### 1. Update SessionExpiredToast component + +**File**: `packages/web/src/common/utils/toast/session-expired.toast.tsx` + +**Before**: + +- Message: "Google Calendar connection expired. Please reconnect." +- Button: "Reconnect Google Calendar" → calls `useGoogleAuth().login()`. + +**After**: + +- Message: "Session expired. Please sign in again." +- Button: "Sign in" → calls `useAuthModal().openModal("login")`, then `toast.dismiss(toastId)`. + +**Implementation**: + +```tsx +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; + +export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { + const { openModal } = useAuthModal(); + + const handleSignIn = () => { + openModal("login"); + toast.dismiss(toastId); + }; + + return ( +
+

+ Session expired. Please sign in again. +

+ +
+ ); +}; +``` + +- Remove `useGoogleAuth` import. +- `SessionExpiredToast` must be rendered within `AuthModalProvider` to use `useAuthModal`. See step 2. + +### 2. Ensure SessionExpiredToast has access to AuthModalProvider + +**File**: `packages/web/src/components/CompassProvider/CompassProvider.tsx` + +Currently `ToastContainer` is a sibling of `AuthModalProvider`, so toast content may not be inside the provider tree (depending on where react-toastify portals render). + +**Option A** (preferred): Move `ToastContainer` inside `AuthModalProvider` so toast children have context access. + +```tsx + + {props.children} + + + +``` + +**Option B**: Wrap the entire subtree (including ToastContainer) in a provider that’s guaranteed to contain the toast portal. Some toast libraries render into `document.body`; in that case, the component that renders the toast content is still a React child of the provider that mounted it. Verify react-toastify behavior. + +After the change, `SessionExpiredToast` should be able to call `useAuthModal()` without error. + +### 3. Update tests + +**File**: `packages/web/src/common/utils/toast/session-expired.toast.test.tsx` + +- Update expectations: + - Message: "Session expired. Please sign in again." + - Button: "Sign in" (or getByRole("button", { name: /sign in/i })). + - Mock `useAuthModal` instead of `useGoogleAuth`. + - Assert `openModal("login")` is called on button click. + - Assert `toast.dismiss` is called. + +**File**: `packages/web/src/common/utils/toast/error-toast.util.ts` + +- No changes; `showSessionExpiredToast` already renders `SessionExpiredToast`. + +### 4. Ensure 401 flow still triggers the toast + +**Files**: + +- `packages/web/src/common/apis/compass.api.ts` – interceptor calls `showSessionExpiredToast()` on 401. +- `packages/web/src/auth/context/UserProvider.tsx` – `getProfile` catch calls `showSessionExpiredToast()` on 401. + +No changes needed; they already show the toast. The toast content is what changed. + +## Test Plan + +1. **Unit** + + ```bash + yarn test:web + ``` + + - `session-expired.toast.test.tsx` passes with new assertions. + +2. **Manual** + - Sign in (Google or email/password). + - Expire or revoke session (e.g. clear cookies, wait, or use devtools). + - Trigger a 401 (e.g. navigate to a protected route or refresh). + - Verify toast appears with new message and "Sign in" button. + - Click "Sign in" → AuthModal opens on login view. + - Sign in with either method → modal closes, session restored. + +## Validation Commands + +```bash +yarn test:web +yarn dev:web +# Manual: trigger 401, verify toast and AuthModal behavior +``` + +## Rollback + +Revert the PR. Toast reverts to Google-only "Reconnect Google Calendar" behavior. diff --git a/PW-PLAN-4.md b/PW-PLAN-4.md new file mode 100644 index 000000000..cb1c57c72 --- /dev/null +++ b/PW-PLAN-4.md @@ -0,0 +1,134 @@ +# PR 4: Connect Google (Account Linking) + +**Branch**: `feature/connect-google-account-linking` +**Goal**: Allow users who signed up with email/password to connect their Google account. Link Google identity to the existing Compass user. Sync Compass-only events to Google when connecting. + +**Depends on**: PR 1, PR 2. + +## Success Criteria + +- Signed-in email/password user can click "Connect Google Calendar" and complete OAuth. +- Google identity is linked to the existing user (no new user created). +- User can sign in from another device with either email/password or Google and see the same data. +- Compass-only events (no `gEventId`) are pushed to Google when connecting; `gEventId` is set. +- All tests pass. + +## Changes + +### 1. Supertokens account linking + +**File**: `packages/backend/src/common/middleware/supertokens.middleware.ts` + +- Enable account linking for ThirdPartyEmailPassword (or equivalent). +- Configure `shouldDoAutomaticAccountLinking`: when a user has a valid session and completes Google OAuth, link the Google identity to that session’s user instead of creating a new one. +- Ensure the backend receives a signal that this is a "link" flow vs "sign up/sign in" (e.g. session exists when OAuth callback is processed). + +### 2. "Connect Google" flow – frontend + +**File**: `packages/web/src/auth/hooks/oauth/useConnectGoogle.ts` (or new hook) + +- When user is signed in with email/password and clicks "Connect Google Calendar": + - Call Google OAuth with `prompt: 'consent'` to ensure refresh token is returned. + - Send OAuth code to backend with a flag like `?link=true` or `linkGoogleAccount=true`. +- Backend route distinguishes: + - No session + OAuth → normal sign-in/sign-up (existing flow). + - Session exists + OAuth + `link=true` → link flow. + +**File**: `packages/web/src/auth/hooks/oauth/useGoogleAuth.ts` + +- Add optional parameter: `linkAccount?: boolean`. When true, call a different backend endpoint (e.g. `POST /auth/link-google`) instead of `/signinup`. + +### 3. Backend: link Google to existing user + +**File**: `packages/backend/src/auth/services/compass.auth.service.ts` + +Add `linkGoogleAccount`: + +1. Require valid session (user is signed in). +2. Get `userId` from session. +3. Receive Google OAuth result (tokens, user info). +4. Update MongoDB user: `$set: { google: { googleId, picture, gRefreshToken } }`. +5. Link ThirdParty (Google) to the Supertokens user via account linking API. +6. Run `syncCompassOnlyEventsToGoogle(userId)` (see step 5). +7. Run `userService.startGoogleCalendarSync(userId)` to import Google calendars and start watches. +8. Return success. + +**File**: `packages/backend/src/auth/` (routes) + +- Add `POST /auth/link-google` (or similar). Accept OAuth code, validate session, call `linkGoogleAccount`. + +### 4. Google sign-in for already-linked users + +**File**: `packages/backend/src/auth/services/compass.auth.service.ts` + +- In the existing `signInUp` override (Google OAuth callback): + - If `response.createdNewRecipeUser` is false (user already exists in Supertokens), it may be a linked user signing in with Google. Ensure we look up Compass user by `google.googleId`. + - If found, proceed with `googleSignin` (update token, incremental sync). + - No new Compass user creation when the Google identity is already linked. + +### 5. Sync Compass-only events to Google + +**File**: `packages/backend/src/event/services/event.service.ts` or new util + +Add `syncCompassOnlyEventsToGoogle(userId: string)`: + +1. Query events: `{ user: userId, isSomeday: false, $or: [{ gEventId: { $exists: false } }, { gEventId: null }] }`. +2. For each event (and its instances if recurring), call `_createGcal(userId, event)`. +3. Update the event in MongoDB with the returned `gEventId` (and `gRecurringEventId` for instances). +4. Run after linking Google, before or after `startGoogleCalendarSync`. + +**File**: `packages/backend/src/auth/services/compass.auth.service.ts` + +- In `linkGoogleAccount`: call `syncCompassOnlyEventsToGoogle(userId)` before `startGoogleCalendarSync`. + +### 6. UI: "Connect Google Calendar" when user has no Google + +**File**: `packages/web/src/auth/hooks/oauth/useConnectGoogle.ts` + +- `isGoogleCalendarConnected`: today this equals `authenticated`. Change to: + - `authenticated && hasGoogleConnected`, where `hasGoogleConnected` comes from profile or a new `/user/profile` field. +- When `authenticated && !hasGoogleConnected`, show "Connect Google Calendar" which triggers the link flow (not full login). + +**File**: `packages/backend/src/user/controllers/user.controller.ts` and `user.service.ts` + +- Extend `getProfile` to include `hasGoogleConnected: boolean` (or derive on frontend from `picture` or a new field). Simplest: add `hasGoogleConnected: !!user?.google?.gRefreshToken` to the profile response. + +### 7. Update `manuallyCreateOrUpdateUser` for link flow + +**File**: `packages/backend/src/common/middleware/supertokens.middleware.ts` + +- When processing Google OAuth in a "link" context, `manuallyCreateOrUpdateUser` may be called. Ensure it finds the existing Compass user by session’s userId, not by `google.googleId`, when in link mode. This depends on how Supertokens invokes the override during linking. + +### 8. Edge cases + +- **User connects Google with same email as Compass account**: No conflict; we’re linking. +- **User connects Google with different email** (e.g. user1@yahoo.com Compass, user1@gmail.com Google): Allow it. We link by Compass userId from session. +- **User already has Google linked, clicks Connect again**: Idempotent; refresh token is updated. + +## Test Plan + +1. **Unit** + - `linkGoogleAccount`: mock user, Google tokens; assert user updated, `syncCompassOnlyEventsToGoogle` called. + - `syncCompassOnlyEventsToGoogle`: mock events without `gEventId`; assert `_createGcal` called, events updated. + +2. **Integration** + - Sign up with email/password → create events (no Google) → connect Google → verify events appear in Google Calendar with `gEventId` set. + - Sign out → sign in with Google (same linked account) → verify same events. + - Sign out → sign in with email/password → verify same events. + +3. **Manual** + - Full flow: sign up (email/pw) → create 2–3 events → connect Google → check Google Calendar → sign out → sign in with Google on another browser → verify events. + +## Validation Commands + +```bash +yarn test:core +yarn test:web +yarn test:backend +yarn dev:web +# Manual: sign up email/pw → create events → connect Google → verify +``` + +## Rollback + +Revert the PR. Linked users will still have `google` set in MongoDB; they can continue signing in with Google. The "Connect" UI and link endpoint will be removed. diff --git a/PW-PLAN-5.md b/PW-PLAN-5.md new file mode 100644 index 000000000..5f04b5e50 --- /dev/null +++ b/PW-PLAN-5.md @@ -0,0 +1,128 @@ +# PR 5: Tests & Polish + +**Branch**: `feature/auth-tests-and-polish` +**Goal**: Add comprehensive tests for new auth flows, rename auth state for clarity, and apply optional improvements. + +**Depends on**: PR 1–4 (can be done in parallel with PR 4 or after). + +## Success Criteria + +- New auth flows have unit and integration tests. +- Auth state naming is consistent (`hasAuthenticated` vs `isGoogleAuthenticated`). +- No regressions in existing tests. + +## Changes + +### 1. Rename auth state (optional but recommended) + +**File**: `packages/web/src/common/constants/auth.constants.ts` + +- Rename `isGoogleAuthenticated` → `hasAuthenticated` in `AuthStateSchema`. +- Default: `hasAuthenticated: false`. + +**File**: `packages/web/src/common/utils/storage/auth-state.util.ts` + +- `markUserAsAuthenticated()`: set `hasAuthenticated: true` (was `isGoogleAuthenticated: true`). +- `hasUserEverAuthenticated()`: return `getAuthState().hasAuthenticated`. +- Add backward compatibility: when reading old state with `isGoogleAuthenticated`, treat it as `hasAuthenticated` for one migration cycle, or require a one-time migration. + +**Files that reference**: + +- `packages/web/src/auth/session/SessionProvider.tsx` +- `packages/web/src/auth/context/UserProvider.tsx` +- `packages/web/src/common/repositories/event/event.repository.util.ts` +- Tests that mock `getAuthState` or `hasUserEverAuthenticated` + +Update all references. + +### 2. Unit tests – backend + +**New/updated tests**: + +- `packages/backend/src/auth/services/compass.auth.service.test.ts` (or extend existing): + - `emailPasswordSignup` creates user without `google`, creates session. + - `emailPasswordSignin` finds user by email, creates session. + - `linkGoogleAccount` (if in scope) updates user with `google`, does not create new user. + +- `packages/backend/src/user/services/user.service.test.ts`: + - `hasGoogleConnected` returns true when `user.google.gRefreshToken` exists. + - `hasGoogleConnected` returns false when `user.google` is missing or `gRefreshToken` is absent. + - `initUserDataEmailPassword` creates user without `google`. + - `getProfile` returns placeholder picture when `user.google` is missing. + +- `packages/backend/src/event/classes/compass.event.parser.test.ts`: + - When `hasGoogleConnected` is false, `createEvent` does not call `_createGcal`. + - When `hasGoogleConnected` is false, `updateEvent` does not call `_updateGcal`. + +### 3. Unit tests – frontend + +**New/updated tests**: + +- `packages/web/src/components/AuthModal/AuthModal.test.tsx`: + - `handleSignUp` calls `AuthApi.signUpEmailPassword` with form data. + - `handleLogin` calls `AuthApi.signInEmailPassword` with form data. + - Success flow: marks authenticated, closes modal, triggers fetch. + +- `packages/web/src/common/utils/storage/auth-state.util.test.ts`: + - Update for `hasAuthenticated` if renamed. + - `markUserAsAuthenticated` sets `hasAuthenticated: true`. + - `hasUserEverAuthenticated` returns value of `hasAuthenticated`. + +- `packages/web/src/common/repositories/event/event.repository.util.test.ts`: + - Update mocks for `hasUserEverAuthenticated`; ensure repository selection works for both auth types. + +### 4. Integration tests (optional) + +- E2E or Cypress: sign up with email/password, create event, sign out, sign in with email/password, verify event visible. (If E2E is set up.) +- API integration: `POST /auth/signup` returns session; `GET /user/profile` with session returns profile. + +### 5. Mock handlers + +**File**: `packages/web/src/__tests__/__mocks__/server/mock.handlers.ts` + +- Add handlers for new auth endpoints (`/auth/signup`, `/auth/signin`, `/auth/link-google`) so frontend tests can run without backend. + +### 6. Documentation + +**File**: `AGENTS.md` (or README) + +- Document that Compass supports both Google OAuth and email/password sign-in. +- Note self-hosting: email/password works without Google OAuth credentials. + +### 7. Lint and format + +```bash +yarn prettier . --write +yarn lint +``` + +## Test Plan + +1. Run full suite: + + ```bash + yarn test:core + yarn test:web + yarn test:backend + ``` + +2. Verify no flaky tests. + +3. Manual smoke: + - Sign up (email/pw) → create event → sign out → sign in → verify. + - Sign in with Google → verify. + - Session expired toast → sign in. + +## Validation Commands + +```bash +yarn install --frozen-lockfile --network-timeout 300000 +yarn test:core +yarn test:web +yarn test:backend +yarn prettier . --write +``` + +## Rollback + +Revert the PR. Only test and naming changes; no functional regression if reverted. diff --git a/PW-PLAN-MASTER.md b/PW-PLAN-MASTER.md new file mode 100644 index 000000000..3bb91ec9f --- /dev/null +++ b/PW-PLAN-MASTER.md @@ -0,0 +1,53 @@ +# User/Password Sign-up: Master Plan + +Add email/password authentication to Compass alongside Google OAuth, enabling: + +- Sign up/sign in without Google +- Self-hosting without Google dependency +- Connecting Google Calendar later +- Signing in from any device with either email/password or Google OAuth + +## PR Sequence + +| PR | Plan File | Name | Purpose | +| --- | ---------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| 1 | [PW-PLAN-1.md](PW-PLAN-1.md) | User schema & guards | Make `google` optional, add `hasGoogleConnected`, guard GCal call sites. **No behavior change for existing users.** | +| 2 | [PW-PLAN-2.md](PW-PLAN-2.md) | Email/Password auth | Add Supertokens EmailPassword, backend sign-up/sign-in, wire AuthModal. | +| 3 | [PW-PLAN-3.md](PW-PLAN-3.md) | Session toast | Generic "Session expired" message; open AuthModal instead of Google-only. | +| 4 | [PW-PLAN-4.md](PW-PLAN-4.md) | Connect Google (linking) | Link Google to email/pw account; sync Compass-only events to Google. | +| 5 | [PW-PLAN-5.md](PW-PLAN-5.md) | Tests & polish | E2E/unit tests; auth state naming; optional improvements. | + +## Dependencies + +- PR 1 must merge first (foundation for all others). +- PR 2 depends on PR 1. +- PR 3 depends on PR 2 (AuthModal must support both auth methods). +- PR 4 depends on PR 2 and PR 1. +- PR 5 can run in parallel or after PR 4. + +## Rollout Safety + +- Each PR is self-contained and mergable independently. +- PR 1 and PR 3 introduce no new user-facing flows; they only harden existing behavior. +- PR 2 enables the new flow; PR 4 extends it. +- All PRs include validation steps and test commands. + +## Key Files + +| Area | Path | +| ------------- | ------------------------------------------------------------------ | +| Supertokens | `packages/backend/src/common/middleware/supertokens.middleware.ts` | +| User schema | `packages/core/src/types/user.types.ts` | +| User service | `packages/backend/src/user/services/user.service.ts` | +| Google auth | `packages/backend/src/auth/services/google.auth.service.ts` | +| Event parser | `packages/backend/src/event/classes/compass.event.parser.ts` | +| Event service | `packages/backend/src/event/services/event.service.ts` | +| Session toast | `packages/web/src/common/utils/toast/session-expired.toast.tsx` | +| Auth modal | `packages/web/src/components/AuthModal/AuthModal.tsx` | +| Auth state | `packages/web/src/common/utils/storage/auth-state.util.ts` | + +## Open Decisions + +1. **Auth state**: Rename `isGoogleAuthenticated` → `hasAuthenticated` (PR 5). +2. **Profile picture**: Placeholder for email/pw users (PR 2). +3. **Self-hosted mode**: Optional env to hide Google when not configured (future). From 58a2a9aee3b2512a3d3ef0d85e4d68fe6c257d6d Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 18:14:24 -0800 Subject: [PATCH 02/15] fix(user.types): make google property optional in Schema_User interface - Updated the Schema_User interface to make the google property optional, allowing for greater flexibility in user data representation. - This change enhances compatibility with users who may not have linked a Google account. --- packages/core/src/types/user.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 4925e528e..d034d0123 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -7,7 +7,7 @@ export interface Schema_User { lastName: string; name: string; locale: string; - google: { + google?: { googleId: string; picture: string; gRefreshToken: string; From f14b83698ff20748d858b9e728b422043a90ef2a Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 18:19:04 -0800 Subject: [PATCH 03/15] test(auth): add unit tests for Google Calendar authentication flow - Introduced a new test suite for the `getGcalClient` function, covering scenarios where users are not connected to Google Calendar or lack a valid refresh token. - Enhanced error handling tests to ensure appropriate exceptions are thrown for users without Google accounts or invalid tokens. - Added tests for the `isGoogleNotConnectedError` utility to verify its functionality in identifying specific error types. - Mocked user queries to isolate tests and improve reliability. --- .../auth/services/google.auth.service.test.ts | 87 +++++++++++++++++++ .../src/auth/services/google.auth.service.ts | 20 ++++- .../src/common/errors/user/user.errors.ts | 11 +++ 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/auth/services/google.auth.service.test.ts diff --git a/packages/backend/src/auth/services/google.auth.service.test.ts b/packages/backend/src/auth/services/google.auth.service.test.ts new file mode 100644 index 000000000..0a4312ae2 --- /dev/null +++ b/packages/backend/src/auth/services/google.auth.service.test.ts @@ -0,0 +1,87 @@ +import { GaxiosError } from "gaxios"; +import { ObjectId } from "mongodb"; +import { faker } from "@faker-js/faker"; +import { Schema_User } from "@core/types/user.types"; +import { getGcalClient } from "@backend/auth/services/google.auth.service"; +import { error } from "@backend/common/errors/handlers/error.handler"; +import { + UserError, + isGoogleNotConnectedError, +} from "@backend/common/errors/user/user.errors"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +jest.mock("@backend/user/queries/user.queries", () => ({ + findCompassUserBy: jest.fn(), +})); + +const mockFindCompassUserBy = findCompassUserBy as jest.MockedFunction< + typeof findCompassUserBy +>; + +describe("getGcalClient", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("throws UserError.GoogleNotConnected when user exists but has no google", async () => { + const userId = new ObjectId().toString(); + const userWithoutGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + // google is undefined - user signed up with email/password + }; + + mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); + + await expect(getGcalClient(userId)).rejects.toMatchObject({ + description: UserError.GoogleNotConnected.description, + }); + + expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); + }); + + it("throws UserError.GoogleNotConnected when user has google but no gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithEmptyGoogle = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "", // empty token - invalid + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithEmptyGoogle); + + await expect(getGcalClient(userId)).rejects.toMatchObject({ + description: UserError.GoogleNotConnected.description, + }); + }); + + it("throws GaxiosError when user is not found", async () => { + const userId = new ObjectId().toString(); + mockFindCompassUserBy.mockResolvedValue(null); + + await expect(getGcalClient(userId)).rejects.toThrow(GaxiosError); + expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); + }); + + it("isGoogleNotConnectedError returns true for GoogleNotConnected error", () => { + const googleNotConnectedError = error( + UserError.GoogleNotConnected, + "User has not connected Google Calendar", + ); + + expect(isGoogleNotConnectedError(googleNotConnectedError)).toBe(true); + expect(isGoogleNotConnectedError(new Error("other"))).toBe(false); + }); +}); diff --git a/packages/backend/src/auth/services/google.auth.service.ts b/packages/backend/src/auth/services/google.auth.service.ts index c1bc23d19..f5e0ec381 100644 --- a/packages/backend/src/auth/services/google.auth.service.ts +++ b/packages/backend/src/auth/services/google.auth.service.ts @@ -43,6 +43,13 @@ export const getGAuthClientForUser = async ( throw error(UserError.UserNotFound, "Auth client not initialized"); } + if (!_user?.google?.gRefreshToken) { + throw error( + UserError.GoogleNotConnected, + "User has not connected Google Calendar", + ); + } + gRefreshToken = _user.google.gRefreshToken; } @@ -61,7 +68,7 @@ export const getGcalClient = async (userId: string): Promise => { // throw gaxios error here to trigger specific session invalidation // see error.express.handler.ts - const error = new GaxiosError( + const gaxiosErr = new GaxiosError( "invalid_grant", { headers: new Headers(), @@ -101,8 +108,15 @@ export const getGcalClient = async (userId: string): Promise => { }, }, ); - error.code = "400"; - throw error; + gaxiosErr.code = "400"; + throw gaxiosErr; + } + + if (!user.google?.gRefreshToken) { + throw error( + UserError.GoogleNotConnected, + "User has not connected Google Calendar", + ); } const gAuthClient = await getGAuthClientForUser(user); diff --git a/packages/backend/src/common/errors/user/user.errors.ts b/packages/backend/src/common/errors/user/user.errors.ts index 91ba04703..b1ca2d0cb 100644 --- a/packages/backend/src/common/errors/user/user.errors.ts +++ b/packages/backend/src/common/errors/user/user.errors.ts @@ -1,3 +1,4 @@ +import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { ErrorMetadata } from "@backend/common/types/error.types"; @@ -6,6 +7,7 @@ interface UserErrors { MissingGoogleUserField: ErrorMetadata; MissingUserIdField: ErrorMetadata; UserNotFound: ErrorMetadata; + GoogleNotConnected: ErrorMetadata; } export const UserError: UserErrors = { @@ -29,4 +31,13 @@ export const UserError: UserErrors = { status: Status.NOT_FOUND, isOperational: true, }, + GoogleNotConnected: { + description: "User has not connected Google Calendar", + status: Status.BAD_REQUEST, + isOperational: true, + }, }; + +export const isGoogleNotConnectedError = (e: unknown): e is BaseError => + e instanceof BaseError && + e.description === UserError.GoogleNotConnected.description; From 838a77d48e975fd042be06e6d7061825e8c6d760 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 18:23:25 -0800 Subject: [PATCH 04/15] refactor(event): streamline Google Calendar event handling - Removed unnecessary variable assignments in Google Calendar event creation and update methods, simplifying the return logic to always return the operation summary. - Enhanced delete operations to handle cases where the event ID may not be present, defaulting to a successful operation when the ID is absent. - Improved code clarity and maintainability by reducing redundancy in event handling methods. --- .../src/event/classes/compass.event.parser.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/event/classes/compass.event.parser.ts b/packages/backend/src/event/classes/compass.event.parser.ts index 21c5efa4d..2502f9a41 100644 --- a/packages/backend/src/event/classes/compass.event.parser.ts +++ b/packages/backend/src/event/classes/compass.event.parser.ts @@ -191,9 +191,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _createGcal(userId, cEvent); + await _createGcal(userId, cEvent); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -222,9 +222,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _updateGcal(userId, cEvent as Schema_Event_Core); + await _updateGcal(userId, cEvent as Schema_Event_Core); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -316,9 +316,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _updateGcal(userId, cEvent as Schema_Event_Core); + await _updateGcal(userId, cEvent as Schema_Event_Core); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -347,7 +347,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(userId, cEvent.gEventId!); + const ok = cEvent.gEventId + ? await _deleteGcal(userId, cEvent.gEventId) + : true; return ok ? [operationSummary] : []; } @@ -379,7 +381,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(user, this.#event.gEventId!); + const ok = this.#event.gEventId + ? await _deleteGcal(user, this.#event.gEventId) + : true; return ok ? [operationSummary] : []; } @@ -418,7 +422,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(user, this.#event.gEventId!); + const ok = this.#event.gEventId + ? await _deleteGcal(user, this.#event.gEventId) + : true; return ok ? [operationSummary] : []; } @@ -455,9 +461,9 @@ export class CompassEventParser { case CalendarProvider.GOOGLE: { Object.assign(cEvent, { recurrence: null }); - const event = await _updateGcal(userId, cEvent); + await _updateGcal(userId, cEvent); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -490,9 +496,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _updateGcal(userId, cEvent); + await _updateGcal(userId, cEvent); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -516,7 +522,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(userId, this.#event.gEventId!); + const ok = this.#event.gEventId + ? await _deleteGcal(userId, this.#event.gEventId) + : true; return ok ? [operationSummary] : []; } From 33c2e7c385d5c66f81948a60350950ffe3e87a4c Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 19:00:27 -0800 Subject: [PATCH 05/15] refactor(auth): update Google authentication error handling - Replaced instances of UserError.GoogleNotConnected with UserError.MissingGoogleField in the Google authentication service and tests to improve clarity and accuracy in error reporting. - Updated related test cases to reflect the new error handling logic, ensuring that appropriate exceptions are thrown when users lack necessary Google account fields. - Removed unused error handling functions to streamline the codebase and enhance maintainability. --- .../auth/services/google.auth.service.test.ts | 24 ++++--------------- .../src/auth/services/google.auth.service.ts | 10 ++------ .../src/common/errors/user/user.errors.ts | 18 ++++---------- 3 files changed, 11 insertions(+), 41 deletions(-) diff --git a/packages/backend/src/auth/services/google.auth.service.test.ts b/packages/backend/src/auth/services/google.auth.service.test.ts index 0a4312ae2..c3d069551 100644 --- a/packages/backend/src/auth/services/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google.auth.service.test.ts @@ -3,11 +3,7 @@ import { ObjectId } from "mongodb"; import { faker } from "@faker-js/faker"; import { Schema_User } from "@core/types/user.types"; import { getGcalClient } from "@backend/auth/services/google.auth.service"; -import { error } from "@backend/common/errors/handlers/error.handler"; -import { - UserError, - isGoogleNotConnectedError, -} from "@backend/common/errors/user/user.errors"; +import { UserError } from "@backend/common/errors/user/user.errors"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; jest.mock("@backend/user/queries/user.queries", () => ({ @@ -23,7 +19,7 @@ describe("getGcalClient", () => { jest.clearAllMocks(); }); - it("throws UserError.GoogleNotConnected when user exists but has no google", async () => { + it("throws UserError.MissingGoogleField when user exists but has no google", async () => { const userId = new ObjectId().toString(); const userWithoutGoogle: Schema_User & { _id: ObjectId } = { _id: new ObjectId(userId), @@ -38,13 +34,13 @@ describe("getGcalClient", () => { mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); await expect(getGcalClient(userId)).rejects.toMatchObject({ - description: UserError.GoogleNotConnected.description, + description: UserError.MissingGoogleField.description, }); expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); }); - it("throws UserError.GoogleNotConnected when user has google but no gRefreshToken", async () => { + it("throws UserError.MissingGoogleField when user has google but no gRefreshToken", async () => { const userId = new ObjectId().toString(); const userWithEmptyGoogle = { _id: new ObjectId(userId), @@ -63,7 +59,7 @@ describe("getGcalClient", () => { mockFindCompassUserBy.mockResolvedValue(userWithEmptyGoogle); await expect(getGcalClient(userId)).rejects.toMatchObject({ - description: UserError.GoogleNotConnected.description, + description: UserError.MissingGoogleField.description, }); }); @@ -74,14 +70,4 @@ describe("getGcalClient", () => { await expect(getGcalClient(userId)).rejects.toThrow(GaxiosError); expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); }); - - it("isGoogleNotConnectedError returns true for GoogleNotConnected error", () => { - const googleNotConnectedError = error( - UserError.GoogleNotConnected, - "User has not connected Google Calendar", - ); - - expect(isGoogleNotConnectedError(googleNotConnectedError)).toBe(true); - expect(isGoogleNotConnectedError(new Error("other"))).toBe(false); - }); }); diff --git a/packages/backend/src/auth/services/google.auth.service.ts b/packages/backend/src/auth/services/google.auth.service.ts index f5e0ec381..4546682d3 100644 --- a/packages/backend/src/auth/services/google.auth.service.ts +++ b/packages/backend/src/auth/services/google.auth.service.ts @@ -44,10 +44,7 @@ export const getGAuthClientForUser = async ( } if (!_user?.google?.gRefreshToken) { - throw error( - UserError.GoogleNotConnected, - "User has not connected Google Calendar", - ); + throw error(UserError.MissingGoogleField, "Auth client not initialized"); } gRefreshToken = _user.google.gRefreshToken; @@ -113,10 +110,7 @@ export const getGcalClient = async (userId: string): Promise => { } if (!user.google?.gRefreshToken) { - throw error( - UserError.GoogleNotConnected, - "User has not connected Google Calendar", - ); + throw error(UserError.MissingGoogleField, "Google client not initialized"); } const gAuthClient = await getGAuthClientForUser(user); diff --git a/packages/backend/src/common/errors/user/user.errors.ts b/packages/backend/src/common/errors/user/user.errors.ts index b1ca2d0cb..09c9415e4 100644 --- a/packages/backend/src/common/errors/user/user.errors.ts +++ b/packages/backend/src/common/errors/user/user.errors.ts @@ -4,10 +4,9 @@ import { ErrorMetadata } from "@backend/common/types/error.types"; interface UserErrors { InvalidValue: ErrorMetadata; - MissingGoogleUserField: ErrorMetadata; + MissingGoogleField: ErrorMetadata; MissingUserIdField: ErrorMetadata; UserNotFound: ErrorMetadata; - GoogleNotConnected: ErrorMetadata; } export const UserError: UserErrors = { @@ -16,9 +15,9 @@ export const UserError: UserErrors = { status: Status.BAD_REQUEST, isOperational: true, }, - MissingGoogleUserField: { - description: "Email field is missing from the Google user object", - status: Status.NOT_FOUND, + MissingGoogleField: { + description: "Field is missing from the Google user object", + status: Status.BAD_REQUEST, isOperational: true, }, MissingUserIdField: { @@ -31,13 +30,4 @@ export const UserError: UserErrors = { status: Status.NOT_FOUND, isOperational: true, }, - GoogleNotConnected: { - description: "User has not connected Google Calendar", - status: Status.BAD_REQUEST, - isOperational: true, - }, }; - -export const isGoogleNotConnectedError = (e: unknown): e is BaseError => - e instanceof BaseError && - e.description === UserError.GoogleNotConnected.description; From cb74f8f15bab9f5053c50f6441b4b119c9b28b7d Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 19:29:05 -0800 Subject: [PATCH 06/15] feat(google): implement Google connection guards and middleware - Added `isGoogleConnected` and `requireGoogleConnection` functions to validate Google account connections for users. - Created corresponding tests for the Google guard to ensure accurate functionality and error handling. - Developed middleware functions `requireGoogleConnectionSession` and `requireGoogleConnectionFrom` to enforce Google connection requirements in route handlers. - Updated event and sync routes to utilize the new middleware, ensuring that Google connection is verified before allowing access to certain operations. - Enhanced test coverage for middleware to validate behavior under various scenarios, including missing user IDs and connection errors. --- .../src/common/guards/google.guard.test.ts | 140 ++++++++++++++++++ .../backend/src/common/guards/google.guard.ts | 19 +++ .../google.required.middleware.test.ts | 129 ++++++++++++++++ .../middleware/google.required.middleware.ts | 58 ++++++++ .../backend/src/event/event.routes.config.ts | 8 +- .../backend/src/sync/sync.routes.config.ts | 18 ++- 6 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/common/guards/google.guard.test.ts create mode 100644 packages/backend/src/common/guards/google.guard.ts create mode 100644 packages/backend/src/common/middleware/google.required.middleware.test.ts create mode 100644 packages/backend/src/common/middleware/google.required.middleware.ts diff --git a/packages/backend/src/common/guards/google.guard.test.ts b/packages/backend/src/common/guards/google.guard.test.ts new file mode 100644 index 000000000..13b85c751 --- /dev/null +++ b/packages/backend/src/common/guards/google.guard.test.ts @@ -0,0 +1,140 @@ +import { ObjectId } from "mongodb"; +import { faker } from "@faker-js/faker"; +import { Schema_User } from "@core/types/user.types"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { + isGoogleConnected, + requireGoogleConnection, +} from "@backend/common/guards/google.guard"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +jest.mock("@backend/user/queries/user.queries", () => ({ + findCompassUserBy: jest.fn(), +})); + +const mockFindCompassUserBy = findCompassUserBy as jest.MockedFunction< + typeof findCompassUserBy +>; + +describe("google.guard", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("hasGoogleConnected", () => { + it("returns true when user has google.gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "valid-refresh-token", + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithGoogle); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(true); + expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); + }); + + it("returns false when user has no google", async () => { + const userId = new ObjectId().toString(); + const userWithoutGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + }; + + mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(false); + }); + + it("returns false when user has google but empty gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithEmptyGoogle = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "", + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithEmptyGoogle); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(false); + }); + + it("returns false when user is not found", async () => { + const userId = new ObjectId().toString(); + mockFindCompassUserBy.mockResolvedValue(null); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(false); + }); + }); + + describe("requireGoogleConnected", () => { + it("does not throw when user has google.gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "valid-refresh-token", + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithGoogle); + + await expect(requireGoogleConnection(userId)).resolves.not.toThrow(); + }); + + it("throws when user has no google.gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithoutGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + }; + + mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); + + await expect(requireGoogleConnection(userId)).rejects.toMatchObject({ + description: UserError.MissingGoogleField.description, + }); + }); + }); +}); diff --git a/packages/backend/src/common/guards/google.guard.ts b/packages/backend/src/common/guards/google.guard.ts new file mode 100644 index 000000000..3ba16ffd2 --- /dev/null +++ b/packages/backend/src/common/guards/google.guard.ts @@ -0,0 +1,19 @@ +import { error } from "@backend/common/errors/handlers/error.handler"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +export const isGoogleConnected = async (userId: string): Promise => { + const user = await findCompassUserBy("_id", userId); + return !!user?.google?.gRefreshToken; +}; + +export const requireGoogleConnection = async ( + userId: string, +): Promise => { + if (!(await isGoogleConnected(userId))) { + throw error( + UserError.MissingGoogleField, + "User has not connected Google Calendar", + ); + } +}; diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts new file mode 100644 index 000000000..01d816a35 --- /dev/null +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -0,0 +1,129 @@ +import { Request, Response } from "express"; +import { ObjectId } from "mongodb"; +import { BaseError } from "@core/errors/errors.base"; +import { Status } from "@core/errors/status.codes"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { requireGoogleConnection } from "@backend/common/guards/google.guard"; +import { + requireGoogleConnectionFrom, + requireGoogleConnectionSession, +} from "@backend/common/middleware/google.required.middleware"; + +jest.mock("@backend/common/guards/google.guard", () => ({ + requireGoogleConnection: jest.fn(), +})); + +const mockRequireGoogleConnection = + requireGoogleConnection as jest.MockedFunction< + typeof requireGoogleConnection + >; + +describe("google.required.middleware", () => { + let mockReq: Partial string } }>; + let mockRes: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockNext = jest.fn(); + mockRes = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + }); + + describe("requireGoogleConnectedSession", () => { + it("calls next when user has Google connected", async () => { + const userId = new ObjectId().toString(); + mockReq = { + session: { getUserId: () => userId }, + }; + mockRequireGoogleConnection.mockResolvedValue(undefined); + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRequireGoogleConnection).toHaveBeenCalledWith(userId); + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it("responds with 400 when userId is missing", async () => { + mockReq = { session: undefined }; + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRequireGoogleConnection).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith( + UserError.MissingUserIdField.status, + ); + expect(mockRes.json).toHaveBeenCalledWith(UserError.MissingUserIdField); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("responds with BaseError statusCode when requireGoogleConnected throws", async () => { + const userId = new ObjectId().toString(); + mockReq = { + session: { getUserId: () => userId }, + }; + const baseError = new BaseError( + "User has not connected Google Calendar", + UserError.MissingGoogleField.description, + Status.BAD_REQUEST, + true, + ); + mockRequireGoogleConnection.mockRejectedValue(baseError); + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); + expect(mockRes.send).toHaveBeenCalledWith(baseError); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe("requireGoogleConnectedFrom", () => { + it("calls next when user has Google connected", async () => { + const userId = new ObjectId().toString(); + mockReq = { + params: { userId }, + }; + mockRequireGoogleConnection.mockResolvedValue(undefined); + + const middleware = requireGoogleConnectionFrom("userId"); + await middleware(mockReq as Request, mockRes as Response, mockNext); + + expect(mockRequireGoogleConnection).toHaveBeenCalledWith(userId); + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it("responds with 400 when param userId is missing", async () => { + mockReq = { + params: {}, + }; + + const middleware = requireGoogleConnectionFrom("userId"); + await middleware(mockReq as Request, mockRes as Response, mockNext); + + expect(mockRequireGoogleConnection).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith( + UserError.MissingUserIdField.status, + ); + expect(mockRes.json).toHaveBeenCalledWith(UserError.MissingUserIdField); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/common/middleware/google.required.middleware.ts b/packages/backend/src/common/middleware/google.required.middleware.ts new file mode 100644 index 000000000..2b80e244c --- /dev/null +++ b/packages/backend/src/common/middleware/google.required.middleware.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import { NextFunction } from "express"; +import { SessionRequest } from "supertokens-node/framework/express"; +import { BaseError } from "@core/errors/errors.base"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { requireGoogleConnection } from "@backend/common/guards/google.guard"; + +export const requireGoogleConnectionSession = async ( + req: SessionRequest, + res: Response, + next: NextFunction, +) => { + const userId = req.session?.getUserId(); + + if (!userId) { + res + .status(UserError.MissingUserIdField.status) + .json(UserError.MissingUserIdField); + return; + } + + try { + await requireGoogleConnection(userId); + } catch (e) { + if (e instanceof BaseError) { + res.status(e.statusCode).send(e); + return; + } + throw e; + } + + next(); +}; + +export const requireGoogleConnectionFrom = + (paramKey = "userId") => + async (req: Request, res: Response, next: NextFunction) => { + const userId = req.params[paramKey]; + + if (!userId) { + res + .status(UserError.MissingUserIdField.status) + .json(UserError.MissingUserIdField); + return; + } + + try { + await requireGoogleConnection(userId); + } catch (e) { + if (e instanceof BaseError) { + res.status(e.statusCode).send(e); + return; + } + throw e; + } + + next(); + }; diff --git a/packages/backend/src/event/event.routes.config.ts b/packages/backend/src/event/event.routes.config.ts index 391d08539..6e6a35725 100644 --- a/packages/backend/src/event/event.routes.config.ts +++ b/packages/backend/src/event/event.routes.config.ts @@ -2,6 +2,7 @@ import express from "express"; import { verifySession } from "supertokens-node/recipe/session/framework/express"; import authMiddleware from "@backend/auth/middleware/auth.middleware"; import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import { requireGoogleConnectionSession } from "@backend/common/middleware/google.required.middleware"; import eventController from "./controllers/event.controller"; export class EventRoutes extends CommonRoutesConfig { @@ -14,8 +15,7 @@ export class EventRoutes extends CommonRoutesConfig { .route(`/api/event`) .all(verifySession()) .get(eventController.readAll) - //@ts-ignore - .post(eventController.create); + .post(requireGoogleConnectionSession, eventController.create); this.app .route(`/api/event/deleteMany`) @@ -36,8 +36,8 @@ export class EventRoutes extends CommonRoutesConfig { .route(`/api/event/:id`) .all(verifySession()) .get(eventController.readById) - .put(eventController.update) - .delete(eventController.delete); + .put(requireGoogleConnectionSession, eventController.update) + .delete(requireGoogleConnectionSession, eventController.delete); return this.app; } diff --git a/packages/backend/src/sync/sync.routes.config.ts b/packages/backend/src/sync/sync.routes.config.ts index e8ddee4ab..f22451f07 100644 --- a/packages/backend/src/sync/sync.routes.config.ts +++ b/packages/backend/src/sync/sync.routes.config.ts @@ -6,6 +6,10 @@ import { } from "@core/constants/core.constants"; import authMiddleware from "@backend/auth/middleware/auth.middleware"; import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import { + requireGoogleConnectionFrom, + requireGoogleConnectionSession, +} from "@backend/common/middleware/google.required.middleware"; import { SyncController } from "@backend/sync/controllers/sync.controller"; import syncDebugController from "@backend/sync/controllers/sync.debug.controller"; @@ -31,7 +35,11 @@ export class SyncRoutes extends CommonRoutesConfig { this.app .route(`/api/sync/import-gcal`) - .post(verifySession(), SyncController.importGCal); + .post( + verifySession(), + requireGoogleConnectionSession, + SyncController.importGCal, + ); /*************** * DEBUG ROUTES @@ -44,6 +52,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/import-incremental/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.importIncremental, ]); @@ -51,6 +60,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/maintain/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.maintainByUser, ]); @@ -58,6 +68,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/refresh/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.refreshEventWatch, ]); @@ -65,6 +76,8 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/start`) .post([ authMiddleware.verifyIsFromCompass, + verifySession(), + requireGoogleConnectionSession, syncDebugController.startEventWatch, ]); @@ -72,6 +85,8 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/stop`) .post([ authMiddleware.verifyIsFromCompass, + verifySession(), + requireGoogleConnectionSession, syncDebugController.stopWatching, ]); @@ -79,6 +94,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/stop-all/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.stopAllChannelWatches, ]); From c0ba1ce675e8185087eadddaa9500830e2bf0b7f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 19:49:07 -0800 Subject: [PATCH 07/15] refactor(auth): improve error messages and validation for Google connection - Updated error messages in `google.auth.service.ts` for clarity, specifying user ID requirements and Google Calendar connection status. - Enhanced the `UserError` descriptions in `user.errors.ts` to provide more specific feedback regarding missing Google refresh tokens. - Added validation in `google.guard.ts` to check for valid ObjectId format for user IDs, improving error handling in the Google connection guard. - Expanded tests in `google.guard.test.ts` to cover scenarios for invalid user IDs and ensure proper error propagation in middleware. --- .../src/auth/services/google.auth.service.ts | 14 +++++++---- .../src/common/errors/user/user.errors.ts | 3 +-- .../src/common/guards/google.guard.test.ts | 9 ++++++++ .../backend/src/common/guards/google.guard.ts | 4 ++++ .../google.required.middleware.test.ts | 23 ++++++++++++++++++- .../middleware/google.required.middleware.ts | 12 ++++++---- 6 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/auth/services/google.auth.service.ts b/packages/backend/src/auth/services/google.auth.service.ts index 4546682d3..9be0203ad 100644 --- a/packages/backend/src/auth/services/google.auth.service.ts +++ b/packages/backend/src/auth/services/google.auth.service.ts @@ -33,18 +33,21 @@ export const getGAuthClientForUser = async ( if (!userId) { logger.error(`Expected to either get a user or a userId.`); - throw error(UserError.InvalidValue, "Auth client not initialized"); + throw error(UserError.InvalidValue, "User id is required"); } const _user = await findCompassUserBy("_id", userId); if (!_user) { logger.error(`Couldn't find user with this id: ${userId}`); - throw error(UserError.UserNotFound, "Auth client not initialized"); + throw error(UserError.UserNotFound, "User not found"); } if (!_user?.google?.gRefreshToken) { - throw error(UserError.MissingGoogleField, "Auth client not initialized"); + throw error( + UserError.MissingGoogleField, + "User has not connected Google Calendar", + ); } gRefreshToken = _user.google.gRefreshToken; @@ -110,7 +113,10 @@ export const getGcalClient = async (userId: string): Promise => { } if (!user.google?.gRefreshToken) { - throw error(UserError.MissingGoogleField, "Google client not initialized"); + throw error( + UserError.MissingGoogleField, + "User has not connected Google Calendar", + ); } const gAuthClient = await getGAuthClientForUser(user); diff --git a/packages/backend/src/common/errors/user/user.errors.ts b/packages/backend/src/common/errors/user/user.errors.ts index 09c9415e4..13f1aa32f 100644 --- a/packages/backend/src/common/errors/user/user.errors.ts +++ b/packages/backend/src/common/errors/user/user.errors.ts @@ -1,4 +1,3 @@ -import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { ErrorMetadata } from "@backend/common/types/error.types"; @@ -16,7 +15,7 @@ export const UserError: UserErrors = { isOperational: true, }, MissingGoogleField: { - description: "Field is missing from the Google user object", + description: "User is missing a Google refresh token", status: Status.BAD_REQUEST, isOperational: true, }, diff --git a/packages/backend/src/common/guards/google.guard.test.ts b/packages/backend/src/common/guards/google.guard.test.ts index 13b85c751..af0ee74fd 100644 --- a/packages/backend/src/common/guards/google.guard.test.ts +++ b/packages/backend/src/common/guards/google.guard.test.ts @@ -119,6 +119,15 @@ describe("google.guard", () => { await expect(requireGoogleConnection(userId)).resolves.not.toThrow(); }); + it("throws when userId is not a valid ObjectId", async () => { + await expect( + requireGoogleConnection("not-an-object-id"), + ).rejects.toMatchObject({ + description: UserError.InvalidValue.description, + }); + expect(mockFindCompassUserBy).not.toHaveBeenCalled(); + }); + it("throws when user has no google.gRefreshToken", async () => { const userId = new ObjectId().toString(); const userWithoutGoogle: Schema_User & { _id: ObjectId } = { diff --git a/packages/backend/src/common/guards/google.guard.ts b/packages/backend/src/common/guards/google.guard.ts index 3ba16ffd2..5c4578fcf 100644 --- a/packages/backend/src/common/guards/google.guard.ts +++ b/packages/backend/src/common/guards/google.guard.ts @@ -1,3 +1,4 @@ +import { IDSchema } from "@core/types/type.utils"; import { error } from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; @@ -10,6 +11,9 @@ export const isGoogleConnected = async (userId: string): Promise => { export const requireGoogleConnection = async ( userId: string, ): Promise => { + if (!IDSchema.safeParse(userId).success) { + throw error(UserError.InvalidValue, "Invalid user id"); + } if (!(await isGoogleConnected(userId))) { throw error( UserError.MissingGoogleField, diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts index 01d816a35..0b94fbca8 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.test.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -89,9 +89,30 @@ describe("google.required.middleware", () => { ); expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); - expect(mockRes.send).toHaveBeenCalledWith(baseError); + expect(mockRes.json).toHaveBeenCalledWith({ + result: baseError.result, + message: baseError.description, + }); expect(mockNext).not.toHaveBeenCalled(); }); + + it("calls next with error when non-BaseError is thrown", async () => { + const userId = new ObjectId().toString(); + mockReq = { + session: { getUserId: () => userId }, + }; + const unexpectedError = new Error("Database connection failed"); + mockRequireGoogleConnection.mockRejectedValue(unexpectedError); + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRes.status).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(unexpectedError); + }); }); describe("requireGoogleConnectedFrom", () => { diff --git a/packages/backend/src/common/middleware/google.required.middleware.ts b/packages/backend/src/common/middleware/google.required.middleware.ts index 2b80e244c..5e389b42f 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.ts @@ -5,6 +5,10 @@ import { BaseError } from "@core/errors/errors.base"; import { UserError } from "@backend/common/errors/user/user.errors"; import { requireGoogleConnection } from "@backend/common/guards/google.guard"; +const sendBaseError = (res: Response, e: BaseError) => { + res.status(e.statusCode).json({ result: e.result, message: e.description }); +}; + export const requireGoogleConnectionSession = async ( req: SessionRequest, res: Response, @@ -23,10 +27,10 @@ export const requireGoogleConnectionSession = async ( await requireGoogleConnection(userId); } catch (e) { if (e instanceof BaseError) { - res.status(e.statusCode).send(e); + sendBaseError(res, e); return; } - throw e; + next(e); } next(); @@ -48,10 +52,10 @@ export const requireGoogleConnectionFrom = await requireGoogleConnection(userId); } catch (e) { if (e instanceof BaseError) { - res.status(e.statusCode).send(e); + sendBaseError(res, e); return; } - throw e; + next(e); } next(); From fd1e5bf5ec11ea352549f40dcb89f9068dcc559d Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 19:51:50 -0800 Subject: [PATCH 08/15] refactor(auth): update error handling for missing Google refresh tokens - Replaced instances of UserError.MissingGoogleField with UserError.MissingGoogleRefreshToken across multiple files to improve clarity in error reporting. - Updated tests in google.auth.service.test.ts, google.guard.test.ts, and google.required.middleware.test.ts to reflect the new error handling logic. - Enhanced UserError definitions in user.errors.ts to provide more specific feedback regarding missing Google refresh tokens. --- .../backend/src/auth/services/google.auth.service.test.ts | 4 ++-- packages/backend/src/auth/services/google.auth.service.ts | 4 ++-- packages/backend/src/common/errors/user/user.errors.ts | 4 ++-- packages/backend/src/common/guards/google.guard.test.ts | 2 +- packages/backend/src/common/guards/google.guard.ts | 2 +- .../src/common/middleware/google.required.middleware.test.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/auth/services/google.auth.service.test.ts b/packages/backend/src/auth/services/google.auth.service.test.ts index c3d069551..8d1f721e6 100644 --- a/packages/backend/src/auth/services/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google.auth.service.test.ts @@ -34,7 +34,7 @@ describe("getGcalClient", () => { mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); await expect(getGcalClient(userId)).rejects.toMatchObject({ - description: UserError.MissingGoogleField.description, + description: UserError.MissingGoogleRefreshToken.description, }); expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); @@ -59,7 +59,7 @@ describe("getGcalClient", () => { mockFindCompassUserBy.mockResolvedValue(userWithEmptyGoogle); await expect(getGcalClient(userId)).rejects.toMatchObject({ - description: UserError.MissingGoogleField.description, + description: UserError.MissingGoogleRefreshToken.description, }); }); diff --git a/packages/backend/src/auth/services/google.auth.service.ts b/packages/backend/src/auth/services/google.auth.service.ts index 9be0203ad..5ee31be69 100644 --- a/packages/backend/src/auth/services/google.auth.service.ts +++ b/packages/backend/src/auth/services/google.auth.service.ts @@ -45,7 +45,7 @@ export const getGAuthClientForUser = async ( if (!_user?.google?.gRefreshToken) { throw error( - UserError.MissingGoogleField, + UserError.MissingGoogleRefreshToken, "User has not connected Google Calendar", ); } @@ -114,7 +114,7 @@ export const getGcalClient = async (userId: string): Promise => { if (!user.google?.gRefreshToken) { throw error( - UserError.MissingGoogleField, + UserError.MissingGoogleRefreshToken, "User has not connected Google Calendar", ); } diff --git a/packages/backend/src/common/errors/user/user.errors.ts b/packages/backend/src/common/errors/user/user.errors.ts index 13f1aa32f..c0870ecbf 100644 --- a/packages/backend/src/common/errors/user/user.errors.ts +++ b/packages/backend/src/common/errors/user/user.errors.ts @@ -3,7 +3,7 @@ import { ErrorMetadata } from "@backend/common/types/error.types"; interface UserErrors { InvalidValue: ErrorMetadata; - MissingGoogleField: ErrorMetadata; + MissingGoogleRefreshToken: ErrorMetadata; MissingUserIdField: ErrorMetadata; UserNotFound: ErrorMetadata; } @@ -14,7 +14,7 @@ export const UserError: UserErrors = { status: Status.BAD_REQUEST, isOperational: true, }, - MissingGoogleField: { + MissingGoogleRefreshToken: { description: "User is missing a Google refresh token", status: Status.BAD_REQUEST, isOperational: true, diff --git a/packages/backend/src/common/guards/google.guard.test.ts b/packages/backend/src/common/guards/google.guard.test.ts index af0ee74fd..bfc9517c5 100644 --- a/packages/backend/src/common/guards/google.guard.test.ts +++ b/packages/backend/src/common/guards/google.guard.test.ts @@ -142,7 +142,7 @@ describe("google.guard", () => { mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); await expect(requireGoogleConnection(userId)).rejects.toMatchObject({ - description: UserError.MissingGoogleField.description, + description: UserError.MissingGoogleRefreshToken.description, }); }); }); diff --git a/packages/backend/src/common/guards/google.guard.ts b/packages/backend/src/common/guards/google.guard.ts index 5c4578fcf..ab98f01bb 100644 --- a/packages/backend/src/common/guards/google.guard.ts +++ b/packages/backend/src/common/guards/google.guard.ts @@ -16,7 +16,7 @@ export const requireGoogleConnection = async ( } if (!(await isGoogleConnected(userId))) { throw error( - UserError.MissingGoogleField, + UserError.MissingGoogleRefreshToken, "User has not connected Google Calendar", ); } diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts index 0b94fbca8..054e1e7d9 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.test.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -76,7 +76,7 @@ describe("google.required.middleware", () => { }; const baseError = new BaseError( "User has not connected Google Calendar", - UserError.MissingGoogleField.description, + UserError.MissingGoogleRefreshToken.description, Status.BAD_REQUEST, true, ); From d75f61cca548697258270db4022661d2375cfd3c Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 19:56:05 -0800 Subject: [PATCH 09/15] chore: remove pw plan docs from tracking (moved to docs/) Made-with: Cursor --- PW-PLAN-1.md | 143 ---------------------------------------- PW-PLAN-2.md | 163 ---------------------------------------------- PW-PLAN-3.md | 133 ------------------------------------- PW-PLAN-4.md | 134 ------------------------------------- PW-PLAN-5.md | 128 ------------------------------------ PW-PLAN-MASTER.md | 53 --------------- 6 files changed, 754 deletions(-) delete mode 100644 PW-PLAN-1.md delete mode 100644 PW-PLAN-2.md delete mode 100644 PW-PLAN-3.md delete mode 100644 PW-PLAN-4.md delete mode 100644 PW-PLAN-5.md delete mode 100644 PW-PLAN-MASTER.md diff --git a/PW-PLAN-1.md b/PW-PLAN-1.md deleted file mode 100644 index cbcbf715e..000000000 --- a/PW-PLAN-1.md +++ /dev/null @@ -1,143 +0,0 @@ -# PR 1: User Schema & GCal Guards - -**Branch**: `feature/add-user-schema-guards` -**Goal**: Make the user model and event/sync logic tolerant of users without Google. No new auth flows; no behavior change for existing Google users. - -## Success Criteria - -- All existing tests pass. -- Google users continue to work exactly as before. -- Code paths that call `getGcalClient` or touch `user.google` safely handle users without Google. -- No user-facing changes. - -## Changes - -### 1. User schema – make `google` optional - -**File**: `packages/core/src/types/user.types.ts` - -- Change `google: { ... }` to `google?: { ... }` in `Schema_User`. -- Update `UserProfile` to handle optional `picture`: use `picture?: string` or derive from `google?.picture` with fallback. - -```ts -// Before -google: { - googleId: string; - picture: string; - gRefreshToken: string; -}; - -// After -google?: { - googleId: string; - picture: string; - gRefreshToken: string; -}; -``` - -### 2. Add `UserError.GoogleNotConnected` and type guard - -**File**: `packages/backend/src/common/errors/user/user.errors.ts` - -- Add `GoogleNotConnected` to the interface and export: - -```ts -GoogleNotConnected: { - description: "User has not connected Google Calendar", - status: Status.BAD_REQUEST, - isOperational: true, -}, -``` - -- Add type guard (in same file or `packages/backend/src/common/errors/user/user.error.utils.ts`): - -```ts -export const isGoogleNotConnectedError = (e: unknown): e is BaseError => - e instanceof BaseError && - e.description === UserError.GoogleNotConnected.description; -``` - -### 3. Update `getGcalClient` and `getGAuthClientForUser` for users without Google - -**File**: `packages/backend/src/auth/services/google.auth.service.ts` - -- In `getGcalClient`: after fetching user, if `!user?.google?.gRefreshToken`, throw `error(UserError.GoogleNotConnected, "User has not connected Google Calendar")` (not the generic GaxiosError). Keep existing behavior when user is not found (still throw `GaxiosError` for session invalidation). -- In `getGAuthClientForUser`: fix `_user.google.gRefreshToken` access (line 45) to handle optional `google` when refetching by userId — throw `error(UserError.GoogleNotConnected, "User has not connected Google Calendar")` if `!_user?.google?.gRefreshToken` instead of accessing undefined. - -Centralizing in `getGcalClient` means one DB fetch and one place to check; no separate `hasGoogleConnected` helper. - -### 4. Event service – catch and handle `GoogleNotConnected` - -**File**: `packages/backend/src/event/services/event.service.ts` - -- In `_getGcal`, `_createGcal`, `_updateGcal`, `_deleteGcal`: wrap `getGcalClient` in try/catch; if `isGoogleNotConnectedError(e)`, return `null` (create/update/get) or no-op (delete). Re-throw other errors. -- Update return types where needed (`GEvent | null` for create/update/get). - -### 5. Parser – treat `null` as Compass-only success - -**File**: `packages/backend/src/event/classes/compass.event.parser.ts` - -- In `createEvent`, `updateEvent`, `updateSeries`, `deleteEvent`, `deleteSeries`: when the event-service GCal method returns `null` (no Google), return `[operationSummary]` — the Compass operation succeeded, GCal was skipped. - -### 6. Other `getGcalClient` call sites – catch and handle - -| File | Context | Action when `isGoogleNotConnectedError` | -| ----------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `packages/backend/src/user/services/user.service.ts` | `startGoogleCalendarSync` | Catch; return `{ eventsCount: 0, calendarsCount: 0 }` | -| `packages/backend/src/user/services/user.service.ts` | `restartGoogleCalendarSync` | Catch; return early / no-op | -| `packages/backend/src/sync/services/sync.service.ts` | `handleGCalNotification`, `importIncremental` | Catch; log and return / skip | -| `packages/backend/src/sync/services/import/sync.import.ts` | `createSyncImport` | When `id` is string, catch and re-throw or return no-op import | -| `packages/backend/src/sync/services/maintain/sync.maintenance.ts` | `prune`, `refreshWatch` | Catch; skip user / early return | -| `packages/backend/src/sync/controllers/sync.debug.controller.ts` | Debug endpoint | Let error propagate — Express handler returns 400 via `UserError.GoogleNotConnected` | -| `packages/backend/src/calendar/services/calendar.service.ts` | Calendar init | Only used after Google OAuth; add catch if called from a path that could run for non-Google users. | - -Express error handler already maps `BaseError` to `res.status(e.statusCode).send(e)`; no change needed. - -Test files and migration scripts can continue to use users with Google; no changes required unless they assert on error behavior. - -### 7. Update user profile / getProfile for optional `google` - -**File**: `packages/backend/src/user/services/user.service.ts` - -- In `getProfile`: `picture` comes from `user.google.picture`. Change to `user.google?.picture ?? ""` or a placeholder (e.g. empty string, or a default avatar URL). -- Ensure projection includes `google.picture` when present. - -### 8. Update `map.user.ts` usage - -**File**: `packages/core/src/mappers/map.user.ts` - -- `mapUserToCompass` is only used for Google users; no change. -- Any other mapper that assumes `user.google` must use optional chaining. - -## Test Plan - -1. **Unit tests** - - ```bash - yarn test:core - yarn test:backend - yarn test:web - ``` - -2. **Regression**: - - Create a test user with Google (use existing fixtures). - - Create/update/delete events; verify GCal sync still works. - - Run sync import; verify no errors. - -3. **New behavior** (mock or create a user without `google`): - - `getGcalClient` throws `UserError.GoogleNotConnected` for user without `google`. - - Event create/update/delete for that user does not call GCal; events are stored in MongoDB only. - -## Validation Commands - -```bash -yarn install --frozen-lockfile --network-timeout 300000 -yarn test:core -yarn test:web -yarn test:backend -yarn prettier . --write -``` - -## Rollback - -Revert the PR. All changes are additive (optional fields, guards). No migrations. diff --git a/PW-PLAN-2.md b/PW-PLAN-2.md deleted file mode 100644 index 0929e0123..000000000 --- a/PW-PLAN-2.md +++ /dev/null @@ -1,163 +0,0 @@ -# PR 2: Email/Password Auth - -**Branch**: `feature/add-email-password-auth` -**Goal**: Enable sign-up and sign-in with email and password. Wire AuthModal to the new backend. - -**Depends on**: PR 1 (user schema and guards). - -## Success Criteria - -- Users can sign up with email, name, and password. -- Users can sign in with email and password. -- Sessions work for email/password users (profile, events, etc.). -- Google OAuth continues to work as before. -- AuthModal sign-up and login forms call the new API. -- All tests pass. - -## Changes - -### 1. Add Supertokens EmailPassword recipe - -**File**: `packages/backend/src/common/middleware/supertokens.middleware.ts` - -- Replace `ThirdParty` with `ThirdPartyEmailPassword` from `supertokens-node/recipe/thirdpartyemailpassword`. -- Configure both Google (existing) and EmailPassword. -- Keep existing `signInUp` override for Google; add overrides for `emailPasswordSignUp` and `emailPasswordSignIn` if custom logic is needed. -- Ensure Supertokens exposes the new routes (signup, signin for email/password). Refer to [ThirdPartyEmailPassword docs](https://supertokens.com/docs/thirdpartyemailpassword/introduction). - -**Alternative**: Add `EmailPassword` as a separate recipe alongside `ThirdParty` if ThirdPartyEmailPassword doesn’t fit. The important part is having email/password sign-up and sign-in endpoints. - -### 2. Backend: email/password sign-up flow - -**File**: `packages/backend/src/auth/services/compass.auth.service.ts` - -Add `emailPasswordSignup`: - -1. Supertokens creates the EmailPassword user (handled by recipe). -2. Get the Supertokens user ID from the sign-up response. -3. Create Compass user in MongoDB: `email`, `firstName`, `lastName`, `name`, `locale` from request body. **No `google` field.** -4. Call `userService.initUserDataEmailPassword` (new method) to create user, default priorities, and metadata. -5. Create session via `Session.createNewSessionWithoutRequestResponse` (or use Supertokens default session creation). -6. Return session tokens to client. - -**File**: `packages/backend/src/user/services/user.service.ts` - -Add `initUserDataEmailPassword`: - -- Accept `{ email, firstName, lastName, name?, locale? }`. -- Create user document without `google`. -- Create default priorities. -- Return the created user. - -### 3. Backend: email/password sign-in flow - -**File**: `packages/backend/src/auth/services/compass.auth.service.ts` - -Add `emailPasswordSignin`: - -1. Validate credentials via Supertokens EmailPassword recipe. -2. Look up Compass user by email. -3. Create session. -4. Return session tokens. - -If the user doesn’t exist in MongoDB (legacy or edge case), create a minimal user or return an error. - -### 4. Backend: auth routes - -**File**: `packages/backend/src/auth/` (routes or controllers) - -- Add `POST /auth/signup` (or use Supertokens FDI endpoint) for email/password sign-up. -- Add `POST /auth/signin` (or use Supertokens FDI endpoint) for email/password sign-in. - -Supertokens may expose these automatically; if so, add a custom middleware/override to create the Compass user and metadata on sign-up. - -### 5. Frontend: Auth API - -**File**: `packages/web/src/common/apis/auth.api.ts` - -Add: - -```ts -async signUpEmailPassword(data: { email: string; password: string; firstName: string; lastName: string; name?: string }): Promise -async signInEmailPassword(data: { email: string; password: string }): Promise -``` - -These call the new backend endpoints and return session/redirect info. - -### 6. Frontend: wire AuthModal - -**File**: `packages/web/src/components/AuthModal/AuthModal.tsx` - -- `handleSignUp`: call `AuthApi.signUpEmailPassword` with form data. On success: `markUserAsAuthenticated()`, `setAuthenticated(true)`, dispatch auth success, close modal. On error: show error. -- `handleLogin`: call `AuthApi.signInEmailPassword`. Same success/error handling. -- `handleForgotPassword`: TODO for a later PR; show a stub or disable. - -### 7. Profile for users without Google - -**File**: `packages/backend/src/user/services/user.service.ts` - -- `getProfile` already handles optional `google` (from PR 1). Ensure `picture` fallback: use empty string or a placeholder (e.g. initials-based URL or `/avatar-placeholder.svg`). - -**File**: `packages/core/src/types/user.types.ts` - -- `UserProfile.picture` should allow empty string if no Google picture. - -### 8. Auth state and repository selection - -**File**: `packages/web/src/common/utils/storage/auth-state.util.ts` - -- `markUserAsAuthenticated()`: already updates `isGoogleAuthenticated` to true. Keep as-is for now (semantic: “user has authenticated”, even if via email/pw). PR 5 can rename. -- `hasUserEverAuthenticated()`: used to decide LocalEventRepository vs RemoteEventRepository. Email/password sign-in should also call `markUserAsAuthenticated()` so repository selection is correct. - -### 9. Sign-up / sign-in success flow - -After successful email/password auth: - -1. Call `markUserAsAuthenticated()`. -2. Call `session` SDK to store session (Supertokens frontend handles this if using their React SDK). -3. Dispatch auth success (Redux). -4. Trigger event fetch (e.g. `triggerFetch()`). -5. Optionally run `syncLocalEventsToCloud()` if the user had local events before signing up (same as Google flow). - -**File**: `packages/web/src/common/utils/auth/` (create if needed) - -- Add `authenticateEmailPassword` similar to `authenticate` for Google, or extend `authenticate` to accept type and data. - -### 10. Session init for email/password - -**File**: `packages/web/src/auth/session/SessionProvider.tsx` - -- Supertokens Session recipe treats all sessions the same. No change needed if using the same session recipe for both auth methods. -- Ensure `session.doesSessionExist()` returns true after email/password sign-in. - -## Test Plan - -1. **Unit tests** - - `compass.auth.service`: test `emailPasswordSignup` and `emailPasswordSignin` with mocks. - - `user.service`: test `initUserDataEmailPassword`. - - AuthModal: test that submit calls the correct API. - -2. **Integration** - - Sign up with email/password → session exists → profile loads → can create events. - - Sign in with email/password → session exists → events load. - - Sign out → session cleared. - - Google OAuth still works. - -3. **Manual** - - Open AuthModal → Sign up with new email → verify account created, can create events. - - Sign out → Sign in with same email/password → verify events visible. - -## Validation Commands - -```bash -yarn install --frozen-lockfile --network-timeout 300000 -yarn test:core -yarn test:web -yarn test:backend -yarn dev:web -# Manual: sign up, sign in, create event, sign out, sign in again -``` - -## Rollback - -Revert the PR. If users were created via email/password, they will still exist in the DB; they just won’t be able to sign in until this feature is re-enabled. diff --git a/PW-PLAN-3.md b/PW-PLAN-3.md deleted file mode 100644 index ab1cfce6b..000000000 --- a/PW-PLAN-3.md +++ /dev/null @@ -1,133 +0,0 @@ -# PR 3: Session Expired Toast - -**Branch**: `feature/update-session-expired-toast` -**Goal**: Replace Google-specific "reconnect" message with a generic "Session expired" toast that opens AuthModal (supports both email/password and Google). - -**Depends on**: PR 2 recommended (so both email/password and Google work in AuthModal). Can merge before PR 2—toast would open AuthModal, but only Google sign-in would work until PR 2. - -## Success Criteria - -- On 401, toast shows "Session expired. Please sign in again." -- Button label: "Sign in" (not "Reconnect Google Calendar"). -- Clicking the button opens AuthModal on the login view. -- User can sign in with email/password or Google. -- All tests pass. - -## Changes - -### 1. Update SessionExpiredToast component - -**File**: `packages/web/src/common/utils/toast/session-expired.toast.tsx` - -**Before**: - -- Message: "Google Calendar connection expired. Please reconnect." -- Button: "Reconnect Google Calendar" → calls `useGoogleAuth().login()`. - -**After**: - -- Message: "Session expired. Please sign in again." -- Button: "Sign in" → calls `useAuthModal().openModal("login")`, then `toast.dismiss(toastId)`. - -**Implementation**: - -```tsx -import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; - -export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { - const { openModal } = useAuthModal(); - - const handleSignIn = () => { - openModal("login"); - toast.dismiss(toastId); - }; - - return ( -
-

- Session expired. Please sign in again. -

- -
- ); -}; -``` - -- Remove `useGoogleAuth` import. -- `SessionExpiredToast` must be rendered within `AuthModalProvider` to use `useAuthModal`. See step 2. - -### 2. Ensure SessionExpiredToast has access to AuthModalProvider - -**File**: `packages/web/src/components/CompassProvider/CompassProvider.tsx` - -Currently `ToastContainer` is a sibling of `AuthModalProvider`, so toast content may not be inside the provider tree (depending on where react-toastify portals render). - -**Option A** (preferred): Move `ToastContainer` inside `AuthModalProvider` so toast children have context access. - -```tsx - - {props.children} - - - -``` - -**Option B**: Wrap the entire subtree (including ToastContainer) in a provider that’s guaranteed to contain the toast portal. Some toast libraries render into `document.body`; in that case, the component that renders the toast content is still a React child of the provider that mounted it. Verify react-toastify behavior. - -After the change, `SessionExpiredToast` should be able to call `useAuthModal()` without error. - -### 3. Update tests - -**File**: `packages/web/src/common/utils/toast/session-expired.toast.test.tsx` - -- Update expectations: - - Message: "Session expired. Please sign in again." - - Button: "Sign in" (or getByRole("button", { name: /sign in/i })). - - Mock `useAuthModal` instead of `useGoogleAuth`. - - Assert `openModal("login")` is called on button click. - - Assert `toast.dismiss` is called. - -**File**: `packages/web/src/common/utils/toast/error-toast.util.ts` - -- No changes; `showSessionExpiredToast` already renders `SessionExpiredToast`. - -### 4. Ensure 401 flow still triggers the toast - -**Files**: - -- `packages/web/src/common/apis/compass.api.ts` – interceptor calls `showSessionExpiredToast()` on 401. -- `packages/web/src/auth/context/UserProvider.tsx` – `getProfile` catch calls `showSessionExpiredToast()` on 401. - -No changes needed; they already show the toast. The toast content is what changed. - -## Test Plan - -1. **Unit** - - ```bash - yarn test:web - ``` - - - `session-expired.toast.test.tsx` passes with new assertions. - -2. **Manual** - - Sign in (Google or email/password). - - Expire or revoke session (e.g. clear cookies, wait, or use devtools). - - Trigger a 401 (e.g. navigate to a protected route or refresh). - - Verify toast appears with new message and "Sign in" button. - - Click "Sign in" → AuthModal opens on login view. - - Sign in with either method → modal closes, session restored. - -## Validation Commands - -```bash -yarn test:web -yarn dev:web -# Manual: trigger 401, verify toast and AuthModal behavior -``` - -## Rollback - -Revert the PR. Toast reverts to Google-only "Reconnect Google Calendar" behavior. diff --git a/PW-PLAN-4.md b/PW-PLAN-4.md deleted file mode 100644 index cb1c57c72..000000000 --- a/PW-PLAN-4.md +++ /dev/null @@ -1,134 +0,0 @@ -# PR 4: Connect Google (Account Linking) - -**Branch**: `feature/connect-google-account-linking` -**Goal**: Allow users who signed up with email/password to connect their Google account. Link Google identity to the existing Compass user. Sync Compass-only events to Google when connecting. - -**Depends on**: PR 1, PR 2. - -## Success Criteria - -- Signed-in email/password user can click "Connect Google Calendar" and complete OAuth. -- Google identity is linked to the existing user (no new user created). -- User can sign in from another device with either email/password or Google and see the same data. -- Compass-only events (no `gEventId`) are pushed to Google when connecting; `gEventId` is set. -- All tests pass. - -## Changes - -### 1. Supertokens account linking - -**File**: `packages/backend/src/common/middleware/supertokens.middleware.ts` - -- Enable account linking for ThirdPartyEmailPassword (or equivalent). -- Configure `shouldDoAutomaticAccountLinking`: when a user has a valid session and completes Google OAuth, link the Google identity to that session’s user instead of creating a new one. -- Ensure the backend receives a signal that this is a "link" flow vs "sign up/sign in" (e.g. session exists when OAuth callback is processed). - -### 2. "Connect Google" flow – frontend - -**File**: `packages/web/src/auth/hooks/oauth/useConnectGoogle.ts` (or new hook) - -- When user is signed in with email/password and clicks "Connect Google Calendar": - - Call Google OAuth with `prompt: 'consent'` to ensure refresh token is returned. - - Send OAuth code to backend with a flag like `?link=true` or `linkGoogleAccount=true`. -- Backend route distinguishes: - - No session + OAuth → normal sign-in/sign-up (existing flow). - - Session exists + OAuth + `link=true` → link flow. - -**File**: `packages/web/src/auth/hooks/oauth/useGoogleAuth.ts` - -- Add optional parameter: `linkAccount?: boolean`. When true, call a different backend endpoint (e.g. `POST /auth/link-google`) instead of `/signinup`. - -### 3. Backend: link Google to existing user - -**File**: `packages/backend/src/auth/services/compass.auth.service.ts` - -Add `linkGoogleAccount`: - -1. Require valid session (user is signed in). -2. Get `userId` from session. -3. Receive Google OAuth result (tokens, user info). -4. Update MongoDB user: `$set: { google: { googleId, picture, gRefreshToken } }`. -5. Link ThirdParty (Google) to the Supertokens user via account linking API. -6. Run `syncCompassOnlyEventsToGoogle(userId)` (see step 5). -7. Run `userService.startGoogleCalendarSync(userId)` to import Google calendars and start watches. -8. Return success. - -**File**: `packages/backend/src/auth/` (routes) - -- Add `POST /auth/link-google` (or similar). Accept OAuth code, validate session, call `linkGoogleAccount`. - -### 4. Google sign-in for already-linked users - -**File**: `packages/backend/src/auth/services/compass.auth.service.ts` - -- In the existing `signInUp` override (Google OAuth callback): - - If `response.createdNewRecipeUser` is false (user already exists in Supertokens), it may be a linked user signing in with Google. Ensure we look up Compass user by `google.googleId`. - - If found, proceed with `googleSignin` (update token, incremental sync). - - No new Compass user creation when the Google identity is already linked. - -### 5. Sync Compass-only events to Google - -**File**: `packages/backend/src/event/services/event.service.ts` or new util - -Add `syncCompassOnlyEventsToGoogle(userId: string)`: - -1. Query events: `{ user: userId, isSomeday: false, $or: [{ gEventId: { $exists: false } }, { gEventId: null }] }`. -2. For each event (and its instances if recurring), call `_createGcal(userId, event)`. -3. Update the event in MongoDB with the returned `gEventId` (and `gRecurringEventId` for instances). -4. Run after linking Google, before or after `startGoogleCalendarSync`. - -**File**: `packages/backend/src/auth/services/compass.auth.service.ts` - -- In `linkGoogleAccount`: call `syncCompassOnlyEventsToGoogle(userId)` before `startGoogleCalendarSync`. - -### 6. UI: "Connect Google Calendar" when user has no Google - -**File**: `packages/web/src/auth/hooks/oauth/useConnectGoogle.ts` - -- `isGoogleCalendarConnected`: today this equals `authenticated`. Change to: - - `authenticated && hasGoogleConnected`, where `hasGoogleConnected` comes from profile or a new `/user/profile` field. -- When `authenticated && !hasGoogleConnected`, show "Connect Google Calendar" which triggers the link flow (not full login). - -**File**: `packages/backend/src/user/controllers/user.controller.ts` and `user.service.ts` - -- Extend `getProfile` to include `hasGoogleConnected: boolean` (or derive on frontend from `picture` or a new field). Simplest: add `hasGoogleConnected: !!user?.google?.gRefreshToken` to the profile response. - -### 7. Update `manuallyCreateOrUpdateUser` for link flow - -**File**: `packages/backend/src/common/middleware/supertokens.middleware.ts` - -- When processing Google OAuth in a "link" context, `manuallyCreateOrUpdateUser` may be called. Ensure it finds the existing Compass user by session’s userId, not by `google.googleId`, when in link mode. This depends on how Supertokens invokes the override during linking. - -### 8. Edge cases - -- **User connects Google with same email as Compass account**: No conflict; we’re linking. -- **User connects Google with different email** (e.g. user1@yahoo.com Compass, user1@gmail.com Google): Allow it. We link by Compass userId from session. -- **User already has Google linked, clicks Connect again**: Idempotent; refresh token is updated. - -## Test Plan - -1. **Unit** - - `linkGoogleAccount`: mock user, Google tokens; assert user updated, `syncCompassOnlyEventsToGoogle` called. - - `syncCompassOnlyEventsToGoogle`: mock events without `gEventId`; assert `_createGcal` called, events updated. - -2. **Integration** - - Sign up with email/password → create events (no Google) → connect Google → verify events appear in Google Calendar with `gEventId` set. - - Sign out → sign in with Google (same linked account) → verify same events. - - Sign out → sign in with email/password → verify same events. - -3. **Manual** - - Full flow: sign up (email/pw) → create 2–3 events → connect Google → check Google Calendar → sign out → sign in with Google on another browser → verify events. - -## Validation Commands - -```bash -yarn test:core -yarn test:web -yarn test:backend -yarn dev:web -# Manual: sign up email/pw → create events → connect Google → verify -``` - -## Rollback - -Revert the PR. Linked users will still have `google` set in MongoDB; they can continue signing in with Google. The "Connect" UI and link endpoint will be removed. diff --git a/PW-PLAN-5.md b/PW-PLAN-5.md deleted file mode 100644 index 5f04b5e50..000000000 --- a/PW-PLAN-5.md +++ /dev/null @@ -1,128 +0,0 @@ -# PR 5: Tests & Polish - -**Branch**: `feature/auth-tests-and-polish` -**Goal**: Add comprehensive tests for new auth flows, rename auth state for clarity, and apply optional improvements. - -**Depends on**: PR 1–4 (can be done in parallel with PR 4 or after). - -## Success Criteria - -- New auth flows have unit and integration tests. -- Auth state naming is consistent (`hasAuthenticated` vs `isGoogleAuthenticated`). -- No regressions in existing tests. - -## Changes - -### 1. Rename auth state (optional but recommended) - -**File**: `packages/web/src/common/constants/auth.constants.ts` - -- Rename `isGoogleAuthenticated` → `hasAuthenticated` in `AuthStateSchema`. -- Default: `hasAuthenticated: false`. - -**File**: `packages/web/src/common/utils/storage/auth-state.util.ts` - -- `markUserAsAuthenticated()`: set `hasAuthenticated: true` (was `isGoogleAuthenticated: true`). -- `hasUserEverAuthenticated()`: return `getAuthState().hasAuthenticated`. -- Add backward compatibility: when reading old state with `isGoogleAuthenticated`, treat it as `hasAuthenticated` for one migration cycle, or require a one-time migration. - -**Files that reference**: - -- `packages/web/src/auth/session/SessionProvider.tsx` -- `packages/web/src/auth/context/UserProvider.tsx` -- `packages/web/src/common/repositories/event/event.repository.util.ts` -- Tests that mock `getAuthState` or `hasUserEverAuthenticated` - -Update all references. - -### 2. Unit tests – backend - -**New/updated tests**: - -- `packages/backend/src/auth/services/compass.auth.service.test.ts` (or extend existing): - - `emailPasswordSignup` creates user without `google`, creates session. - - `emailPasswordSignin` finds user by email, creates session. - - `linkGoogleAccount` (if in scope) updates user with `google`, does not create new user. - -- `packages/backend/src/user/services/user.service.test.ts`: - - `hasGoogleConnected` returns true when `user.google.gRefreshToken` exists. - - `hasGoogleConnected` returns false when `user.google` is missing or `gRefreshToken` is absent. - - `initUserDataEmailPassword` creates user without `google`. - - `getProfile` returns placeholder picture when `user.google` is missing. - -- `packages/backend/src/event/classes/compass.event.parser.test.ts`: - - When `hasGoogleConnected` is false, `createEvent` does not call `_createGcal`. - - When `hasGoogleConnected` is false, `updateEvent` does not call `_updateGcal`. - -### 3. Unit tests – frontend - -**New/updated tests**: - -- `packages/web/src/components/AuthModal/AuthModal.test.tsx`: - - `handleSignUp` calls `AuthApi.signUpEmailPassword` with form data. - - `handleLogin` calls `AuthApi.signInEmailPassword` with form data. - - Success flow: marks authenticated, closes modal, triggers fetch. - -- `packages/web/src/common/utils/storage/auth-state.util.test.ts`: - - Update for `hasAuthenticated` if renamed. - - `markUserAsAuthenticated` sets `hasAuthenticated: true`. - - `hasUserEverAuthenticated` returns value of `hasAuthenticated`. - -- `packages/web/src/common/repositories/event/event.repository.util.test.ts`: - - Update mocks for `hasUserEverAuthenticated`; ensure repository selection works for both auth types. - -### 4. Integration tests (optional) - -- E2E or Cypress: sign up with email/password, create event, sign out, sign in with email/password, verify event visible. (If E2E is set up.) -- API integration: `POST /auth/signup` returns session; `GET /user/profile` with session returns profile. - -### 5. Mock handlers - -**File**: `packages/web/src/__tests__/__mocks__/server/mock.handlers.ts` - -- Add handlers for new auth endpoints (`/auth/signup`, `/auth/signin`, `/auth/link-google`) so frontend tests can run without backend. - -### 6. Documentation - -**File**: `AGENTS.md` (or README) - -- Document that Compass supports both Google OAuth and email/password sign-in. -- Note self-hosting: email/password works without Google OAuth credentials. - -### 7. Lint and format - -```bash -yarn prettier . --write -yarn lint -``` - -## Test Plan - -1. Run full suite: - - ```bash - yarn test:core - yarn test:web - yarn test:backend - ``` - -2. Verify no flaky tests. - -3. Manual smoke: - - Sign up (email/pw) → create event → sign out → sign in → verify. - - Sign in with Google → verify. - - Session expired toast → sign in. - -## Validation Commands - -```bash -yarn install --frozen-lockfile --network-timeout 300000 -yarn test:core -yarn test:web -yarn test:backend -yarn prettier . --write -``` - -## Rollback - -Revert the PR. Only test and naming changes; no functional regression if reverted. diff --git a/PW-PLAN-MASTER.md b/PW-PLAN-MASTER.md deleted file mode 100644 index 3bb91ec9f..000000000 --- a/PW-PLAN-MASTER.md +++ /dev/null @@ -1,53 +0,0 @@ -# User/Password Sign-up: Master Plan - -Add email/password authentication to Compass alongside Google OAuth, enabling: - -- Sign up/sign in without Google -- Self-hosting without Google dependency -- Connecting Google Calendar later -- Signing in from any device with either email/password or Google OAuth - -## PR Sequence - -| PR | Plan File | Name | Purpose | -| --- | ---------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| 1 | [PW-PLAN-1.md](PW-PLAN-1.md) | User schema & guards | Make `google` optional, add `hasGoogleConnected`, guard GCal call sites. **No behavior change for existing users.** | -| 2 | [PW-PLAN-2.md](PW-PLAN-2.md) | Email/Password auth | Add Supertokens EmailPassword, backend sign-up/sign-in, wire AuthModal. | -| 3 | [PW-PLAN-3.md](PW-PLAN-3.md) | Session toast | Generic "Session expired" message; open AuthModal instead of Google-only. | -| 4 | [PW-PLAN-4.md](PW-PLAN-4.md) | Connect Google (linking) | Link Google to email/pw account; sync Compass-only events to Google. | -| 5 | [PW-PLAN-5.md](PW-PLAN-5.md) | Tests & polish | E2E/unit tests; auth state naming; optional improvements. | - -## Dependencies - -- PR 1 must merge first (foundation for all others). -- PR 2 depends on PR 1. -- PR 3 depends on PR 2 (AuthModal must support both auth methods). -- PR 4 depends on PR 2 and PR 1. -- PR 5 can run in parallel or after PR 4. - -## Rollout Safety - -- Each PR is self-contained and mergable independently. -- PR 1 and PR 3 introduce no new user-facing flows; they only harden existing behavior. -- PR 2 enables the new flow; PR 4 extends it. -- All PRs include validation steps and test commands. - -## Key Files - -| Area | Path | -| ------------- | ------------------------------------------------------------------ | -| Supertokens | `packages/backend/src/common/middleware/supertokens.middleware.ts` | -| User schema | `packages/core/src/types/user.types.ts` | -| User service | `packages/backend/src/user/services/user.service.ts` | -| Google auth | `packages/backend/src/auth/services/google.auth.service.ts` | -| Event parser | `packages/backend/src/event/classes/compass.event.parser.ts` | -| Event service | `packages/backend/src/event/services/event.service.ts` | -| Session toast | `packages/web/src/common/utils/toast/session-expired.toast.tsx` | -| Auth modal | `packages/web/src/components/AuthModal/AuthModal.tsx` | -| Auth state | `packages/web/src/common/utils/storage/auth-state.util.ts` | - -## Open Decisions - -1. **Auth state**: Rename `isGoogleAuthenticated` → `hasAuthenticated` (PR 5). -2. **Profile picture**: Placeholder for email/pw users (PR 2). -3. **Self-hosted mode**: Optional env to hide Google when not configured (future). From 24558d49a1f1aeae5c57c8f7338f5c0e4c81fa88 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 09:43:23 -0800 Subject: [PATCH 10/15] refactor(middleware): update error response handling in Google middleware - Changed the error response format in `sendBaseError` to use `res.send` instead of `res.json`, simplifying the response structure. - Updated middleware functions to ensure consistent error handling and improved clarity in the response flow. - Enhanced tests to verify the updated behavior of error handling in `google.required.middleware.test.ts`. --- .../common/middleware/google.required.middleware.test.ts | 6 ++---- .../src/common/middleware/google.required.middleware.ts | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts index 054e1e7d9..9c6b505ec 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.test.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -89,10 +89,7 @@ describe("google.required.middleware", () => { ); expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); - expect(mockRes.json).toHaveBeenCalledWith({ - result: baseError.result, - message: baseError.description, - }); + expect(mockRes.send).toHaveBeenCalledWith(baseError); expect(mockNext).not.toHaveBeenCalled(); }); @@ -112,6 +109,7 @@ describe("google.required.middleware", () => { expect(mockRes.status).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalledWith(unexpectedError); + expect(mockNext).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/backend/src/common/middleware/google.required.middleware.ts b/packages/backend/src/common/middleware/google.required.middleware.ts index 5e389b42f..958aa1447 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.ts @@ -6,7 +6,7 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { requireGoogleConnection } from "@backend/common/guards/google.guard"; const sendBaseError = (res: Response, e: BaseError) => { - res.status(e.statusCode).json({ result: e.result, message: e.description }); + res.status(e.statusCode).send(e); }; export const requireGoogleConnectionSession = async ( @@ -30,7 +30,7 @@ export const requireGoogleConnectionSession = async ( sendBaseError(res, e); return; } - next(e); + return next(e); } next(); @@ -55,7 +55,7 @@ export const requireGoogleConnectionFrom = sendBaseError(res, e); return; } - next(e); + return next(e); } next(); From 74faedfe730a276044547d821dde75e1852cf677 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 10:12:34 -0800 Subject: [PATCH 11/15] refactor(middleware): enhance error response structure in Google middleware - Updated the error response handling in `sendBaseError` to use `res.json` for a more structured response format. - Adjusted tests in `google.required.middleware.test.ts` to verify the new response structure, ensuring clarity in error reporting. - Improved logging of errors on the server side for better debugging without exposing sensitive details to clients. --- .../common/middleware/google.required.middleware.test.ts | 5 ++++- .../src/common/middleware/google.required.middleware.ts | 8 +++++++- .../backend/src/event/controllers/event.controller.ts | 2 +- packages/backend/src/user/services/user.service.test.ts | 2 +- packages/core/src/mappers/map.user.test.ts | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts index 9c6b505ec..e953e8a38 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.test.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -89,7 +89,10 @@ describe("google.required.middleware", () => { ); expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); - expect(mockRes.send).toHaveBeenCalledWith(baseError); + expect(mockRes.json).toHaveBeenCalledWith({ + result: baseError.result, + message: baseError.description, + }); expect(mockNext).not.toHaveBeenCalled(); }); diff --git a/packages/backend/src/common/middleware/google.required.middleware.ts b/packages/backend/src/common/middleware/google.required.middleware.ts index 958aa1447..cf42ddd28 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.ts @@ -6,7 +6,13 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { requireGoogleConnection } from "@backend/common/guards/google.guard"; const sendBaseError = (res: Response, e: BaseError) => { - res.status(e.statusCode).send(e); + // Log full error on server; do not expose stack/details to client. + console.error(e); + + res.status(e.statusCode).json({ + result: e.result, + message: e.description, + }); }; export const requireGoogleConnectionSession = async ( diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts index ceb8738c8..7d8dddf4b 100644 --- a/packages/backend/src/event/controllers/event.controller.ts +++ b/packages/backend/src/event/controllers/event.controller.ts @@ -17,7 +17,7 @@ import { Res_Promise, SReqBody } from "@backend/common/types/express.types"; import eventService from "@backend/event/services/event.service"; import { CompassSyncProcessor } from "@backend/sync/services/sync/compass.sync.processor"; -const logger = Logger("app.event.controllers.event.controller"); +const logger = Logger("app:event.controller"); class EventController { private async processEvents(_events: CompassEvent[]) { diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index 7582b88c2..5d4c4c084 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -55,7 +55,7 @@ describe("UserService", () => { expect(profile).toEqual( expect.objectContaining({ userId: userId.toString(), - picture: user.google.picture, + picture: user.google?.picture, firstName: user.firstName, lastName: user.lastName, name: user.name, diff --git a/packages/core/src/mappers/map.user.test.ts b/packages/core/src/mappers/map.user.test.ts index e3cd41949..9b1c93903 100644 --- a/packages/core/src/mappers/map.user.test.ts +++ b/packages/core/src/mappers/map.user.test.ts @@ -19,7 +19,7 @@ describe("Map to Compass", () => { expect(cUser.name).toEqual("Mystery Person"); expect(cUser.firstName).toEqual("Mystery"); expect(cUser.lastName).toEqual("Person"); - expect(cUser.google.picture).toEqual("not provided"); + expect(cUser.google?.picture).toEqual("not provided"); }); it("throws error if missing email", () => { expect(() => { From 19812813c8f434b2b888ec911c1bf717a0a7eb8e Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 10:24:36 -0800 Subject: [PATCH 12/15] refactor(tests): standardize Google connection error handling and test descriptions - Updated test descriptions in `google.auth.service.test.ts`, `google.guard.test.ts`, and `google.required.middleware.test.ts` to consistently reference `UserError.MissingGoogleRefreshToken` for clarity. - Renamed test suites and methods to improve readability and align with updated error handling logic, enhancing overall test structure. - Added a new test case in `google.guard.test.ts` to verify behavior when a user does not exist, ensuring comprehensive coverage of error scenarios. --- .../src/auth/services/google.auth.service.test.ts | 4 ++-- .../backend/src/common/guards/google.guard.test.ts | 13 +++++++++++-- .../middleware/google.required.middleware.test.ts | 11 ++++------- .../backend/src/user/services/user.service.test.ts | 3 ++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/auth/services/google.auth.service.test.ts b/packages/backend/src/auth/services/google.auth.service.test.ts index 8d1f721e6..6ac655539 100644 --- a/packages/backend/src/auth/services/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google.auth.service.test.ts @@ -19,7 +19,7 @@ describe("getGcalClient", () => { jest.clearAllMocks(); }); - it("throws UserError.MissingGoogleField when user exists but has no google", async () => { + it("throws UserError.MissingGoogleRefreshToken when user exists but has no google", async () => { const userId = new ObjectId().toString(); const userWithoutGoogle: Schema_User & { _id: ObjectId } = { _id: new ObjectId(userId), @@ -40,7 +40,7 @@ describe("getGcalClient", () => { expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); }); - it("throws UserError.MissingGoogleField when user has google but no gRefreshToken", async () => { + it("throws UserError.MissingGoogleRefreshToken when user has google but no gRefreshToken", async () => { const userId = new ObjectId().toString(); const userWithEmptyGoogle = { _id: new ObjectId(userId), diff --git a/packages/backend/src/common/guards/google.guard.test.ts b/packages/backend/src/common/guards/google.guard.test.ts index bfc9517c5..19945cf3a 100644 --- a/packages/backend/src/common/guards/google.guard.test.ts +++ b/packages/backend/src/common/guards/google.guard.test.ts @@ -21,7 +21,7 @@ describe("google.guard", () => { jest.clearAllMocks(); }); - describe("hasGoogleConnected", () => { + describe("isGoogleConnected", () => { it("returns true when user has google.gRefreshToken", async () => { const userId = new ObjectId().toString(); const userWithGoogle: Schema_User & { _id: ObjectId } = { @@ -97,7 +97,7 @@ describe("google.guard", () => { }); }); - describe("requireGoogleConnected", () => { + describe("requireGoogleConnection", () => { it("does not throw when user has google.gRefreshToken", async () => { const userId = new ObjectId().toString(); const userWithGoogle: Schema_User & { _id: ObjectId } = { @@ -128,6 +128,15 @@ describe("google.guard", () => { expect(mockFindCompassUserBy).not.toHaveBeenCalled(); }); + it("throws UserError.UserNotFound when user does not exist", async () => { + const userId = new ObjectId().toString(); + mockFindCompassUserBy.mockResolvedValue(null); + + await expect(requireGoogleConnection(userId)).rejects.toMatchObject({ + description: UserError.UserNotFound.description, + }); + }); + it("throws when user has no google.gRefreshToken", async () => { const userId = new ObjectId().toString(); const userWithoutGoogle: Schema_User & { _id: ObjectId } = { diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts index e953e8a38..b3d905771 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.test.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -33,7 +33,7 @@ describe("google.required.middleware", () => { }; }); - describe("requireGoogleConnectedSession", () => { + describe("requireGoogleConnectionSession", () => { it("calls next when user has Google connected", async () => { const userId = new ObjectId().toString(); mockReq = { @@ -69,7 +69,7 @@ describe("google.required.middleware", () => { expect(mockNext).not.toHaveBeenCalled(); }); - it("responds with BaseError statusCode when requireGoogleConnected throws", async () => { + it("responds with BaseError statusCode when requireGoogleConnection throws", async () => { const userId = new ObjectId().toString(); mockReq = { session: { getUserId: () => userId }, @@ -89,10 +89,7 @@ describe("google.required.middleware", () => { ); expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); - expect(mockRes.json).toHaveBeenCalledWith({ - result: baseError.result, - message: baseError.description, - }); + expect(mockRes.send).toHaveBeenCalledWith(baseError); expect(mockNext).not.toHaveBeenCalled(); }); @@ -116,7 +113,7 @@ describe("google.required.middleware", () => { }); }); - describe("requireGoogleConnectedFrom", () => { + describe("requireGoogleConnectionFrom", () => { it("calls next when user has Google connected", async () => { const userId = new ObjectId().toString(); mockReq = { diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index 5d4c4c084..b4e5e95bb 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -50,12 +50,13 @@ describe("UserService", () => { const user = await UserDriver.createUser(); const userId = user._id; + expect(user.google).toBeDefined(); const profile = await userService.getProfile(userId); expect(profile).toEqual( expect.objectContaining({ userId: userId.toString(), - picture: user.google?.picture, + picture: user.google!.picture, firstName: user.firstName, lastName: user.lastName, name: user.name, From 21ece4260ee27fc58382f39d42659c3fe39b0e8c Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 10:33:12 -0800 Subject: [PATCH 13/15] refactor(errors): enhance error handling and introduce client-safe payload - Updated the error handling in `error.express.handler.ts` to utilize a new function, `toClientErrorPayload`, which formats error responses for clients without exposing sensitive details. - Added a new test suite in `error.handler.test.ts` to validate the behavior of `toClientErrorPayload`, ensuring it correctly formats errors and excludes internal properties. - Improved overall error response structure for better clarity and security in client communications. --- .../errors/handlers/error.express.handler.ts | 7 +++- .../errors/handlers/error.handler.test.ts | 41 +++++++++++++++++++ .../common/errors/handlers/error.handler.ts | 9 ++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/common/errors/handlers/error.handler.test.ts diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index f6476db8d..1ab3fa39d 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -5,7 +5,10 @@ import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { IS_DEV } from "@backend/common/constants/env.constants"; -import { errorHandler } from "@backend/common/errors/handlers/error.handler"; +import { + errorHandler, + toClientErrorPayload, +} from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; import { getEmailFromUrl, @@ -76,7 +79,7 @@ export const handleExpressError = async ( errorHandler.log(e); if (e instanceof BaseError) { - res.status(e.statusCode).send(e); + res.status(e.statusCode).json(toClientErrorPayload(e)); } else { const userId = await parseUserId(res, e); if (!userId) { diff --git a/packages/backend/src/common/errors/handlers/error.handler.test.ts b/packages/backend/src/common/errors/handlers/error.handler.test.ts new file mode 100644 index 000000000..74bf83d83 --- /dev/null +++ b/packages/backend/src/common/errors/handlers/error.handler.test.ts @@ -0,0 +1,41 @@ +import { BaseError } from "@core/errors/errors.base"; +import { Status } from "@core/errors/status.codes"; +import { + error, + toClientErrorPayload, +} from "@backend/common/errors/handlers/error.handler"; +import { UserError } from "@backend/common/errors/user/user.errors"; + +describe("error.handler", () => { + describe("toClientErrorPayload", () => { + it("returns only result and message from BaseError", () => { + const baseError = error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); + + const payload = toClientErrorPayload(baseError); + + expect(payload).toEqual({ + result: "User has not connected Google Calendar", + message: UserError.MissingGoogleRefreshToken.description, + }); + }); + + it("excludes stack, statusCode, and isOperational", () => { + const baseError = new BaseError( + "some-result", + "some-description", + Status.BAD_REQUEST, + true, + ); + + const payload = toClientErrorPayload(baseError); + + expect(payload).not.toHaveProperty("stack"); + expect(payload).not.toHaveProperty("statusCode"); + expect(payload).not.toHaveProperty("isOperational"); + expect(Object.keys(payload)).toEqual(["result", "message"]); + }); + }); +}); diff --git a/packages/backend/src/common/errors/handlers/error.handler.ts b/packages/backend/src/common/errors/handlers/error.handler.ts index 243514083..442abe454 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.ts @@ -33,6 +33,15 @@ export const genericError = ( return error(cause, result); }; +/** + * Returns a safe payload for BaseError to send to clients. + * Avoids exposing stack, isOperational, or other internal details. + */ +export const toClientErrorPayload = (e: BaseError) => ({ + result: e.result, + message: e.description, +}); + class ErrorHandler { public isOperational(error: Error): boolean { if (error instanceof BaseError) { From fba13b19ce2a3c0d90abad32e1bd60c0827ff1aa Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 10:33:38 -0800 Subject: [PATCH 14/15] refactor(middleware): improve error handling in Google middleware - Replaced the custom error response function with a standardized error handler that utilizes `toClientErrorPayload` for consistent client-safe error responses. - Updated tests in `google.required.middleware.test.ts` to reflect changes in error response structure, ensuring accurate validation of error handling. - Enhanced logging of errors to improve debugging while maintaining security by not exposing sensitive details to clients. --- .../google.required.middleware.test.ts | 5 ++++- .../middleware/google.required.middleware.ts | 20 ++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts index b3d905771..42b490b4f 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.test.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -89,7 +89,10 @@ describe("google.required.middleware", () => { ); expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); - expect(mockRes.send).toHaveBeenCalledWith(baseError); + expect(mockRes.json).toHaveBeenCalledWith({ + result: baseError.result, + message: baseError.description, + }); expect(mockNext).not.toHaveBeenCalled(); }); diff --git a/packages/backend/src/common/middleware/google.required.middleware.ts b/packages/backend/src/common/middleware/google.required.middleware.ts index cf42ddd28..02b1eece5 100644 --- a/packages/backend/src/common/middleware/google.required.middleware.ts +++ b/packages/backend/src/common/middleware/google.required.middleware.ts @@ -2,19 +2,13 @@ import { Request, Response } from "express"; import { NextFunction } from "express"; import { SessionRequest } from "supertokens-node/framework/express"; import { BaseError } from "@core/errors/errors.base"; +import { + errorHandler, + toClientErrorPayload, +} from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; import { requireGoogleConnection } from "@backend/common/guards/google.guard"; -const sendBaseError = (res: Response, e: BaseError) => { - // Log full error on server; do not expose stack/details to client. - console.error(e); - - res.status(e.statusCode).json({ - result: e.result, - message: e.description, - }); -}; - export const requireGoogleConnectionSession = async ( req: SessionRequest, res: Response, @@ -33,7 +27,8 @@ export const requireGoogleConnectionSession = async ( await requireGoogleConnection(userId); } catch (e) { if (e instanceof BaseError) { - sendBaseError(res, e); + errorHandler.log(e); + res.status(e.statusCode).json(toClientErrorPayload(e)); return; } return next(e); @@ -58,7 +53,8 @@ export const requireGoogleConnectionFrom = await requireGoogleConnection(userId); } catch (e) { if (e instanceof BaseError) { - sendBaseError(res, e); + errorHandler.log(e); + res.status(e.statusCode).json(toClientErrorPayload(e)); return; } return next(e); From 0610ee93d66d273a3343efce58213b7885a29f1f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 10:35:44 -0800 Subject: [PATCH 15/15] refactor(auth): enhance user ID handling and Google connection validation - Improved user ID extraction logic in `google.auth.service.ts` to ensure consistent handling of different ID types. - Refactored `isGoogleConnected` function in `google.guard.test.ts` to include validation for user ID format using `IDSchema`. - Updated `requireGoogleConnection` in `google.guard.ts` to throw a user not found error if the user does not exist, enhancing error handling clarity. --- .../src/auth/services/google.auth.service.ts | 7 ++++++- .../backend/src/common/guards/google.guard.test.ts | 14 ++++++++++---- packages/backend/src/common/guards/google.guard.ts | 11 +++++------ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/auth/services/google.auth.service.ts b/packages/backend/src/auth/services/google.auth.service.ts index 5ee31be69..b46762c44 100644 --- a/packages/backend/src/auth/services/google.auth.service.ts +++ b/packages/backend/src/auth/services/google.auth.service.ts @@ -29,7 +29,12 @@ export const getGAuthClientForUser = async ( } if (!gRefreshToken) { - const userId = "_id" in user ? (user._id as string) : undefined; + const userId = + "_id" in user + ? typeof user._id === "string" + ? user._id + : user._id.toString() + : undefined; if (!userId) { logger.error(`Expected to either get a user or a userId.`); diff --git a/packages/backend/src/common/guards/google.guard.test.ts b/packages/backend/src/common/guards/google.guard.test.ts index 19945cf3a..aa51c7d6e 100644 --- a/packages/backend/src/common/guards/google.guard.test.ts +++ b/packages/backend/src/common/guards/google.guard.test.ts @@ -1,13 +1,19 @@ import { ObjectId } from "mongodb"; import { faker } from "@faker-js/faker"; +import { IDSchema } from "@core/types/type.utils"; import { Schema_User } from "@core/types/user.types"; import { UserError } from "@backend/common/errors/user/user.errors"; -import { - isGoogleConnected, - requireGoogleConnection, -} from "@backend/common/guards/google.guard"; +import { requireGoogleConnection } from "@backend/common/guards/google.guard"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; +const isGoogleConnected = async (userId: string): Promise => { + if (!IDSchema.safeParse(userId).success) { + return false; + } + const user = await findCompassUserBy("_id", userId); + return !!user?.google?.gRefreshToken; +}; + jest.mock("@backend/user/queries/user.queries", () => ({ findCompassUserBy: jest.fn(), })); diff --git a/packages/backend/src/common/guards/google.guard.ts b/packages/backend/src/common/guards/google.guard.ts index ab98f01bb..cb7d91fbb 100644 --- a/packages/backend/src/common/guards/google.guard.ts +++ b/packages/backend/src/common/guards/google.guard.ts @@ -3,18 +3,17 @@ import { error } from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; -export const isGoogleConnected = async (userId: string): Promise => { - const user = await findCompassUserBy("_id", userId); - return !!user?.google?.gRefreshToken; -}; - export const requireGoogleConnection = async ( userId: string, ): Promise => { if (!IDSchema.safeParse(userId).success) { throw error(UserError.InvalidValue, "Invalid user id"); } - if (!(await isGoogleConnected(userId))) { + const user = await findCompassUserBy("_id", userId); + if (!user) { + throw error(UserError.UserNotFound, "User not found"); + } + if (!user.google?.gRefreshToken) { throw error( UserError.MissingGoogleRefreshToken, "User has not connected Google Calendar",