diff --git a/.gitignore b/.gitignore index 497e1e0a0..05e10533a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,7 @@ packages/**/yarn.lock # DIRS # ######## # root -.claude/settings.local.json -.claude/worktrees/ +.claude/ .idea/ .mcp.json .vscode/ diff --git a/SECURITY.md b/SECURITY.md index 9cc0e0f68..8c93c0ebc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in this project, please send an email to **tyler@switchback.tech** with the following details: +If you discover a security vulnerability in this project, please send an email to with the following details: - A description of the vulnerability - Steps to reproduce (if applicable) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..6cacf655b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,57 @@ +# Docs + +Internal documentation for engineers and agents working in the Compass repo. + +Start with [AGENTS.md](../AGENTS.md) for repo rules, commands, and conventions. Use the docs below for codebase shape and subsystem behavior. + +## Start Here + +- [Agent Onboarding](./development/agent-onboarding.md) +- [Repo Architecture](./architecture/repo-architecture.md) +- [Feature File Map](./development/feature-file-map.md) +- [Common Change Recipes](./development/common-change-recipes.md) + +## Common Change Paths + +- Auth or session behavior: + [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md), + [Password Auth Flow](./features/password-auth-flow.md), + [Google Sync And Websocket Flow](./features/google-sync-and-websocket-flow.md) +- Event shape or recurrence behavior: + [Event And Task Domain Model](./architecture/event-and-task-domain-model.md), + [Recurrence Handling](./features/recurring-events-handling.md) +- Local-first or storage behavior: + [Offline Storage And Migrations](./features/offline-storage-and-migrations.md) +- Backend routes and API behavior: + [Backend Request Flow](./backend/backend-request-flow.md), + [API Documentation](./backend/api-documentation.md), + [Backend Error Handling](./backend/backend-error-handling.md) + +## Runtime Flows + +- [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md) +- [Google Sync And Websocket Flow](./features/google-sync-and-websocket-flow.md) +- [Password Auth Flow](./features/password-auth-flow.md) + +## Architecture And Domain + +- [Repo Architecture](./architecture/repo-architecture.md) +- [Event And Task Domain Model](./architecture/event-and-task-domain-model.md) +- [Glossary](./architecture/glossary.md) +- [Engineering Principles](./principles.md) + +## Development And Operations + +- [Env And Dev Modes](./development/env-and-dev-modes.md) +- [Testing Playbook](./development/testing-playbook.md) +- [Types And Validation](./development/types-and-validation.md) +- [CLI And Maintenance Commands](./development/cli-and-maintenance-commands.md) +- [Deploy](./development/deploy.md) +- [Coding Conventions](./development/coding-conventions.md) + +## Feature Deep Dives + +- [Password Auth Flow](./features/password-auth-flow.md) +- [Google Sync And Websocket Flow](./features/google-sync-and-websocket-flow.md) +- [Recurrence Handling](./features/recurring-events-handling.md) +- [Offline Storage And Migrations](./features/offline-storage-and-migrations.md) diff --git a/docs/agent-onboarding.md b/docs/agent-onboarding.md deleted file mode 100644 index 82e92b296..000000000 --- a/docs/agent-onboarding.md +++ /dev/null @@ -1,84 +0,0 @@ -# Agent Onboarding - -This is the fastest accurate path into the Compass codebase for AI agents. - -## Start Here - -Read these in order: - -1. `AGENTS.md` for repo rules, test commands, and naming conventions. -2. `docs/repo-architecture.md` for package boundaries and startup paths. -3. `docs/feature-file-map.md` for "where do I edit?" lookups. -4. `docs/common-change-recipes.md` for safe implementation patterns. - -## Current Ground Truth - -- The monorepo has four active packages: `packages/web`, `packages/backend`, `packages/core`, and `packages/scripts`. -- The frontend can run standalone with `yarn dev:web`. -- The backend requires valid env configuration and external services. -- Shared domain types and validation live primarily in `packages/core/src`. -- Event behavior spans all three runtime packages: `core`, `web`, and `backend`. - -## First Commands To Run - -```bash -rg --files docs packages -sed -n '1,220p' package.json -sed -n '1,220p' packages/web/src/index.tsx -sed -n '1,220p' packages/backend/src/app.ts -sed -n '1,260p' packages/core/src/types/event.types.ts -``` - -## High-Value File Anchors - -- Web boot: `packages/web/src/index.tsx` -- Web root view: `packages/web/src/views/Root.tsx` -- Web router: `packages/web/src/routers/index.tsx` -- Redux store: `packages/web/src/store/index.ts` -- Event sagas: `packages/web/src/ducks/events/sagas/event.sagas.ts` -- Local storage adapter: `packages/web/src/common/storage/adapter/indexeddb.adapter.ts` -- Backend app startup: `packages/backend/src/app.ts` -- Express wiring: `packages/backend/src/servers/express/express.server.ts` -- Event routes: `packages/backend/src/event/event.routes.config.ts` -- Event controller: `packages/backend/src/event/controllers/event.controller.ts` -- Event service: `packages/backend/src/event/services/event.service.ts` -- Sync service: `packages/backend/src/sync/services/sync.service.ts` -- Websocket server: `packages/backend/src/servers/websocket/websocket.server.ts` -- Shared event types: `packages/core/src/types/event.types.ts` -- Shared websocket constants: `packages/core/src/constants/websocket.constants.ts` - -## Working Rules That Matter In Practice - -- Use module aliases instead of deep relative imports. -- Prefer adding or updating Zod schemas next to shared types in `core`. -- For web changes, test from user behavior, not implementation details. -- For backend routes, follow `routes.config -> controller -> service -> query` flow. -- For event changes, inspect both sync directions before editing only one side. -- Be careful with authenticated vs unauthenticated behavior; Compass supports both local-only and remote-backed flows. - -## Known Friction Points - -- Web event state is split between Redux slices/sagas and an Elf event store. -- Event recurrence rules and someday behavior have several transition paths. -- IndexedDB initialization and migrations happen before the app fully boots. -- Once a user has authenticated, event repository selection intentionally prefers remote access even if the current session is gone. - -## If You Are Touching... - -- Auth or session behavior: read `docs/frontend-runtime-flow.md`, `docs/password-auth-flow.md`, and `docs/env-and-dev-modes.md`. -- Backend endpoints: read `docs/backend-request-flow.md`. -- Google sync or websocket behavior: read `docs/google-sync-and-websocket-flow.md`. -- Local persistence: read `docs/offline-storage-and-migrations.md`. -- Event or task shape: read `docs/event-and-task-domain-model.md`. -- Shared schemas: read `docs/types-and-validation.md`. - -## Validation Defaults - -Run the smallest relevant checks first: - -- Core-only changes: `yarn test:core` -- Web-only changes: `yarn test:web` -- Backend-only changes: `yarn test:backend` -- Scripts-only changes: `yarn test:scripts` -- Cross-package type changes: `yarn test:core && yarn test:web && yarn test:backend` -- Before handoff: `yarn type-check` diff --git a/docs/event-and-task-domain-model.md b/docs/architecture/event-and-task-domain-model.md similarity index 97% rename from docs/event-and-task-domain-model.md rename to docs/architecture/event-and-task-domain-model.md index d0cdfec0e..48927959c 100644 --- a/docs/event-and-task-domain-model.md +++ b/docs/architecture/event-and-task-domain-model.md @@ -45,7 +45,7 @@ These are UI-facing categories, not storage categories. Many sync and parser decisions key off transitions between these states. -For the full recurring-event lifecycle, see [recurrence-handling.md](./recurrence-handling.md). +For the full recurring-event lifecycle, see [Recurrence Handling](../features/recurring-events-handling.md). ## Update Scopes diff --git a/docs/architecture/glossary.md b/docs/architecture/glossary.md new file mode 100644 index 000000000..179facb00 --- /dev/null +++ b/docs/architecture/glossary.md @@ -0,0 +1,58 @@ +# Glossary + +Definition of terms used in the source code and documentation. + +## Events + +**Standalone Event**: An event that has a datetime and is NOT recurring. It represents a single occurrence. + +**Grid Event**: An event that is assigned to a specific time slot on the calendar in the grid view. These events have both a date and time. + +**Draft Event**: A calendar event that has pending changes that have not yet been persisted to the database. When a user makes changes to an event in the form, drags the event or resizes its times, the user is operating on a draft event. After the user clicks "Save", the draft event is persisted to the database, and the Draft Event goes away and is replaced with the Event. + +**Someday Event**: These have `startDate` and `endDate` like regular timed events, but they have not yet been assigned to a specific time slot on the calendar in the grid. Instead, they are stored in the sidebar (Someday/Maybe list). These may be recurring or standalone. + +**Base Event**: A _recurring_ event that defines the recurrence pattern. It has the series `RRULE` in the `recurrence` field and serves as a template for generating instances. + +**Instance Event**: A _recurring_ event that is an individual occurrence of a base event. Instances are generated based on the base event's recurrence rule. + +## Calendar Concepts + +**Calendar**: A calendar is a collection of events. It is the main object in the application. In Compass, each user has their primary calendar. + +**Calendar List**: Also known as sub-calendars. A calendar list is a collection of calendars. In Google Calendar, users can have multiple calendars (e.g., "Work", "Personal", "Holidays"). + +**Calendar View**: A calendar view is a way to view a calendar. Compass currently centers on: + +- Day view +- Week view +- Now mode (focus on current task) +- sidebar month widget / someday-month workflows + +**Primary Calendar**: The main calendar associated with a user's Google account. Compass currently syncs only the primary calendar. + +## Sync & Authentication + +**Sync**: The Compass feature that allows users to sync their calendar data with other calendars like Google Calendar. Sync can be bidirectional (changes in Compass update Google Calendar and vice versa). + +**Sync Channel**: A notification channel set up with Google Calendar that notifies Compass when events change. Managed via Google Calendar's watch API. + +**nextSyncToken**: A token provided by Google Calendar API that allows incremental syncing. It represents the state of the calendar at a point in time. + +**gAccessToken**: Google OAuth access token used to authenticate API requests to Google Calendar. + +**gRefreshToken**: Google OAuth refresh token used to obtain new access tokens when they expire. Stored securely in MongoDB. + +## Technical Terms + +**Redux Store**: The centralized state management store for the React frontend. Contains all application state including events, user data, and UI state. + +**Redux Saga**: Middleware for Redux that handles side effects (API calls, async operations) in a declarative way. + +**Duck Pattern**: A Redux pattern that co-locates actions, reducers, and selectors in a single file (or directory) for a feature domain. + +**WebSocket**: A communication protocol used for real-time bidirectional communication between the frontend and backend. Used to push updates when events change. + +**Supertokens**: The authentication library used by Compass to manage user sessions, access tokens, and refresh tokens. + +**MongoDB Collection**: A collection in MongoDB is similar to a table in a relational database. Compass uses collections for users, events, syncs, etc. diff --git a/docs/repo-architecture.md b/docs/architecture/repo-architecture.md similarity index 100% rename from docs/repo-architecture.md rename to docs/architecture/repo-architecture.md diff --git a/docs/api-documentation.md b/docs/backend/api-documentation.md similarity index 98% rename from docs/api-documentation.md rename to docs/backend/api-documentation.md index 23afb14bc..762a0f2eb 100644 --- a/docs/api-documentation.md +++ b/docs/backend/api-documentation.md @@ -123,7 +123,9 @@ Runtime constraints from recipe overrides: - successful password `signUpPOST` and `signInPOST` upsert the Compass user via `userService.upsertUserFromAuth(...)` - password `createNewRecipeUser` ensures SuperTokens external user-id mapping exists and points to a Mongo `ObjectId` string -- password-reset emails are currently logged (dev/test) or logged as disabled (non-dev), not delivered by an external provider +- password-reset links are rewritten to Compass app URLs before delivery +- in `test`, reset links are logged instead of sent +- outside `test`, delivery goes through SuperTokens email delivery --- diff --git a/docs/backend/backend-error-handling.md b/docs/backend/backend-error-handling.md new file mode 100644 index 000000000..89be5c2ce --- /dev/null +++ b/docs/backend/backend-error-handling.md @@ -0,0 +1,50 @@ +# Backend Error Handling + +Compass uses typed operational errors plus a centralized Express error handler. + +## Source Files + +- `packages/backend/src/common/errors/handlers/error.handler.ts` +- `packages/backend/src/common/errors/handlers/error.express.handler.ts` +- feature error metadata files under `packages/backend/src/common/errors/**` +- `packages/core/src/errors/errors.base.ts` + +## Main Pattern + +Preferred backend pattern: + +1. define reusable error metadata in the relevant feature file +2. create a `BaseError` through `error(...)` +3. let controller/service code throw that error +4. let centralized Express handling turn it into the client payload + +Example: + +```ts +import { AuthError } from "@backend/common/errors/auth/auth.errors"; +import { error } from "@backend/common/errors/handlers/error.handler"; + +throw error(AuthError.MissingRefreshToken, "Google connection required"); +``` + +## Client Payload Rules + +For `BaseError`, backend responses are intentionally small: + +- `result`: short result string +- `message`: safe user-facing description + +Internal details such as stack traces and operational flags stay server-side. + +## Unexpected Error Rules + +- non-`BaseError` values are routed through `handleExpressError(...)` +- Google API errors get special handling for revoked tokens, invalid values, and full-sync recovery +- programmer errors can terminate the process after logging + +## Guidance + +- Keep `result` short and stable. +- Put technical detail in logs, not in the client payload. +- Prefer reusing existing feature error metadata before inventing new names. +- If the error should trigger special auth/sync behavior, verify both API handling and websocket side effects. diff --git a/docs/backend-request-flow.md b/docs/backend/backend-request-flow.md similarity index 100% rename from docs/backend-request-flow.md rename to docs/backend/backend-request-flow.md diff --git a/docs/development/agent-onboarding.md b/docs/development/agent-onboarding.md new file mode 100644 index 000000000..48bd7bb01 --- /dev/null +++ b/docs/development/agent-onboarding.md @@ -0,0 +1,83 @@ +# Agent Onboarding + +Fastest accurate path into the Compass repo for internal engineers and agents. + +## Read In This Order + +1. [AGENTS.md](../../AGENTS.md) for repo rules, naming, and command defaults. +2. [Docs Index](../README.md) for topic navigation. +3. [Repo Architecture](../architecture/repo-architecture.md) for package boundaries and startup paths. +4. [Feature File Map](./feature-file-map.md) for "where do I edit?" lookups. +5. [Common Change Recipes](./common-change-recipes.md) for safe implementation patterns. + +## Current Ground Truth + +- Active packages are `packages/web`, `packages/backend`, `packages/core`, and `packages/scripts`. +- `yarn dev:web` works without backend services. +- Backend, sync, and auth work require valid env plus external services. +- Shared domain contracts live mostly in `packages/core/src`. +- Event behavior crosses `core`, `web`, and `backend`. +- Use Tailwind for new styles (we're moving away from `styled-components`). + +## First Commands To Run + +```bash +rg --files docs packages +sed -n '1,220p' package.json +sed -n '1,220p' packages/web/src/index.tsx +sed -n '1,220p' packages/backend/src/app.ts +sed -n '1,260p' packages/core/src/types/event.types.ts +``` + +## High-Value File Anchors + +- Web boot: `packages/web/src/index.tsx` +- Web root view: `packages/web/src/views/Root.tsx` +- Web router: `packages/web/src/routers/index.tsx` +- Redux store: `packages/web/src/store/index.ts` +- Event sagas: `packages/web/src/ducks/events/sagas/event.sagas.ts` +- Local storage adapter: `packages/web/src/common/storage/adapter/indexeddb.adapter.ts` +- Backend app startup: `packages/backend/src/app.ts` +- Express wiring: `packages/backend/src/servers/express/express.server.ts` +- Event routes: `packages/backend/src/event/event.routes.config.ts` +- Event controller: `packages/backend/src/event/controllers/event.controller.ts` +- Event service: `packages/backend/src/event/services/event.service.ts` +- Sync service: `packages/backend/src/sync/services/sync.service.ts` +- Websocket server: `packages/backend/src/servers/websocket/websocket.server.ts` +- Shared event types: `packages/core/src/types/event.types.ts` +- Shared websocket constants: `packages/core/src/constants/websocket.constants.ts` + +## If You Are Touching... + +- Auth or session behavior: + [Frontend Runtime Flow](../frontend/frontend-runtime-flow.md), + [Password Auth Flow](../features/password-auth-flow.md), + [Env And Dev Modes](./env-and-dev-modes.md) +- Backend endpoints: + [Backend Request Flow](../backend/backend-request-flow.md), + [API Documentation](../backend/api-documentation.md) +- Google sync or websocket behavior: + [Google Sync And Websocket Flow](../features/google-sync-and-websocket-flow.md) +- Local persistence: + [Offline Storage And Migrations](../features/offline-storage-and-migrations.md) +- Event or task shape: + [Event And Task Domain Model](../architecture/event-and-task-domain-model.md), + [Recurrence Handling](../features/recurring-events-handling.md) +- Shared schemas: + [Types And Validation](./types-and-validation.md) + +## Known Friction Points + +- Web event state is split across Redux, redux-saga, Elf stores, and IndexedDB-backed repositories. +- Recurrence and someday transitions have several planner paths. +- IndexedDB initialization and migrations happen before the app fully boots. +- Once a user has authenticated, repository selection prefers remote access unless Google is explicitly in a revoked state. + +## Validation Defaults + +- Core-only changes: `yarn test:core` +- Web-only changes: `yarn test:web` +- Backend-only changes: `yarn test:backend` +- Scripts-only changes: `yarn test:scripts` +- Cross-package type changes: `yarn test:core && yarn test:web && yarn test:backend` +- Before handoff: `yarn type-check` diff --git a/docs/cli-and-maintenance-commands.md b/docs/development/cli-and-maintenance-commands.md similarity index 91% rename from docs/cli-and-maintenance-commands.md rename to docs/development/cli-and-maintenance-commands.md index bc48ae0e0..1c70c4e69 100644 --- a/docs/cli-and-maintenance-commands.md +++ b/docs/development/cli-and-maintenance-commands.md @@ -28,12 +28,12 @@ Primary source: How API base URLs are resolved: - local (`--environment local`): returns `BASEURL` directly (trailing slash removed) -- staging (`--environment staging`): uses host from `STAGING_WEB_URL` and builds `https:///api` -- production (`--environment production`): uses host from `PROD_WEB_URL` and builds `https:///api` +- staging/production: derives `https:///api` from `FRONTEND_URL` Fallback behavior: -- if `STAGING_WEB_URL`/`PROD_WEB_URL` are missing, CLI prompts for a domain and builds `https:///api` +- if `FRONTEND_URL` points at `localhost`, CLI prompts for a domain and builds `https:///api` +- if `FRONTEND_URL` is already a non-localhost URL, CLI uses that hostname directly - local mode does not prompt for a domain; it depends on `BASEURL` ## Supported Commands @@ -178,6 +178,7 @@ yarn cli migrate executed - Treat delete flows as destructive unless proven otherwise. - For migration work, inspect existing migration naming and ordering first. - For build work, confirm whether you need `web` or `nodePckgs`. +- `yarn cli` always loads `packages/backend/.env.local` through the root script; build-time environment selection changes which backend env file is copied or loaded by the underlying command. ## Quick Reference diff --git a/docs/development/coding-conventions.md b/docs/development/coding-conventions.md new file mode 100644 index 000000000..de961f7b2 --- /dev/null +++ b/docs/development/coding-conventions.md @@ -0,0 +1,30 @@ +# Coding Conventions + +Use [AGENTS.md](../../AGENTS.md) as the normative source for repo-wide coding rules, branch naming, commit format, testing defaults, and workflow expectations. + +This document keeps only the local conventions that are useful to repeat inside `docs/`. + +## Commits And PRs + +- Use conventional commits in lower-case. +- Do not use emoji prefixes in commit messages. +- Keep branch naming and commit formatting aligned with `AGENTS.md`. + +## Comments + +- Do not use code comments as task tracking. Open an issue instead. +- Prefer self-explanatory code and tests over explanatory comments. +- Add comments only when they explain non-obvious behavior, constraints, or cross-file coupling. +- If a flow needs more than a short code comment, prefer a repo doc and link to it. + +## Cleanup + +- Do not add dead code, speculative scaffolding, or half-implemented branches. +- If you find unused code that is clearly unrelated to your main change, remove it separately when practical. +- Leave touched code a little clearer than you found it: simpler control flow, better naming, or better tests. + +## Shared Contracts + +- Prefer shared Zod-backed contracts in `packages/core` when web and backend both depend on them. +- Use module aliases instead of deep relative imports. +- Follow the established pattern of the area you are editing instead of forcing large stylistic migrations during unrelated work. diff --git a/docs/common-change-recipes.md b/docs/development/common-change-recipes.md similarity index 99% rename from docs/common-change-recipes.md rename to docs/development/common-change-recipes.md index 76ac1f51f..1ef77189e 100644 --- a/docs/common-change-recipes.md +++ b/docs/development/common-change-recipes.md @@ -24,7 +24,7 @@ Rule: never treat event shape as web-only unless the field is strictly presentat ## Change Recurring Event Behavior 1. Read `packages/core/src/types/event.types.ts`. -2. Read `docs/recurrence-handling.md`. +2. Read `docs/features/recurring-events-handling.md`. 3. Read `packages/backend/src/event/classes/compass.event.generator.ts`. 4. Read `packages/backend/src/event/classes/compass.event.parser.ts`. 5. Read `packages/backend/src/event/classes/compass.event.executor.ts`. diff --git a/docs/development/deploy.md b/docs/development/deploy.md new file mode 100644 index 000000000..f7f08e8b1 --- /dev/null +++ b/docs/development/deploy.md @@ -0,0 +1,52 @@ +# Deploy + +Compass deploys as a web frontend plus a Node backend. + +## Runtime Requirements + +To support Google sign-in and sync in non-local environments, you need: + +- an HTTPS-accessible web origin +- an HTTPS-accessible API origin +- matching Google OAuth redirect/origin configuration +- backend env files for the target environment + +For staging or production: + +1. Put runtime values in `packages/backend/.env.staging` or `packages/backend/.env.production`. +2. Set `FRONTEND_URL` to the public web app URL. +3. Set `BASEURL` to the public API base URL, including `/api`. +4. Set `CORS` to the comma-separated list of allowed origins for the backend. +5. Ensure Google Cloud OAuth settings include the deployed web origin and redirect URIs. + +## Web + +Build command: + +```bash +yarn cli build web --environment staging --clientId "test-client-id" +``` + +Webpack outputs static assets to `build/web`. Serve those assets from any static host or reverse proxy setup that can serve the app and `version.json`. + +## Backend (API) + +Build command: + +```bash +yarn cli build nodePckgs --environment staging +``` + +Node build output lands in `build/node` and includes a copied `.env` file for the selected environment when that file exists. + +Runtime entrypoint: + +```bash +node build/node/packages/backend/src/app.js +``` + +Deployment notes: + +- backend requires MongoDB, SuperTokens, and Google credentials +- if you run behind a reverse proxy, it must support websocket upgrades +- ngrok is only relevant for local watch/debug flows, not normal hosted deploys diff --git a/docs/env-and-dev-modes.md b/docs/development/env-and-dev-modes.md similarity index 85% rename from docs/env-and-dev-modes.md rename to docs/development/env-and-dev-modes.md index eaf2c4bca..80f0f3460 100644 --- a/docs/env-and-dev-modes.md +++ b/docs/development/env-and-dev-modes.md @@ -52,7 +52,7 @@ Source: - `packages/backend/src/common/constants/env.constants.ts` -The backend validates required env at startup with Zod. +The backend validates env at startup with Zod. Important variables: @@ -67,13 +67,20 @@ Important variables: - `SUPERTOKENS_KEY` - `TOKEN_GCAL_NOTIFICATION` - `TOKEN_COMPASS_SYNC` -- `LOCAL_WEB_URL` +- `FRONTEND_URL` +- `CORS` (parsed into `ENV.ORIGINS_ALLOWED`) Optional but behavior-changing: - `NGROK_AUTHTOKEN` - `NGROK_DOMAIN` -- emailer-related variables +- `EMAILER_API_SECRET` +- `EMAILER_USER_TAG_ID` + +Derived backend values: + +- `DB` is not supplied directly; backend derives it from `NODE_ENV` +- `ORIGINS_ALLOWED` is derived by splitting the comma-separated `CORS` env var ## CLI And Build URL Variables @@ -85,12 +92,10 @@ Primary files: Variables used by CLI/build flows: -- `BASEURL` (required for local CLI operations; returned as-is for local API base URL) -- `LOCAL_WEB_URL` (required; used by backend auth email flows) -- `STAGING_WEB_URL` (optional; used to derive `https:///api` for staging CLI runs) -- `PROD_WEB_URL` (optional; used to derive `https:///api` for production CLI runs) +- `BASEURL` (used for local CLI operations and injected into the web build as `API_BASEURL`) +- `FRONTEND_URL` (used by backend auth email flows and CLI domain resolution) -If staging/production URL variables are not set, the CLI prompts for a VM domain and builds the API URL from that input. +If `FRONTEND_URL` points to localhost, the CLI prompts for a VM/public domain and builds the API URL from that input. ## Web Environment Contract @@ -110,7 +115,7 @@ Important variables: Webpack behavior (`packages/web/webpack.config.mjs`): -- local/staging/production builds try to load `packages/backend/.env.` +- local/staging/production builds load `packages/backend/.env.local`, `.env.staging`, or `.env.production` - missing env files are a warning, not a hard failure; values can come from `process.env` - test mode skips env-file loading entirely diff --git a/docs/feature-file-map.md b/docs/development/feature-file-map.md similarity index 100% rename from docs/feature-file-map.md rename to docs/development/feature-file-map.md diff --git a/docs/testing-playbook.md b/docs/development/testing-playbook.md similarity index 100% rename from docs/testing-playbook.md rename to docs/development/testing-playbook.md diff --git a/docs/development/troubleshoot.md b/docs/development/troubleshoot.md new file mode 100644 index 000000000..e0fb38176 --- /dev/null +++ b/docs/development/troubleshoot.md @@ -0,0 +1,49 @@ +# Troubleshoot + +## Backend Health Check + +Before debugging a deeper auth or sync issue, confirm the backend is actually up: + +```bash +curl -i http://localhost:3000/api/health +``` + +Interpret the result like this: + +- `200`: the backend is running and can reach MongoDB +- `500`: the backend is running but database connectivity failed +- connection refused or timeout: the backend is not listening yet, or the port/base URL is wrong + +## Unable to Sign In with Google in Local Compass Instance + +### Missing User id + +When you encounter a missing user id, Compass usually is not connected to MongoDB or the backend never started cleanly. + +Sometimes MongoDB is successfully connected when you run `yarn dev:backend` but you still get a missing user id error. This could be because: + +1. The MongoDB connection string in your backend env file is incorrect +2. Your IP address is not whitelisted in MongoDB Atlas +3. The MongoDB connection string format is invalid or incomplete +4. A required backend env variable is missing, so the server exited during startup + +### Mismatch User Id + +When you encounter a mismatch user id, the user in your mongo collection is not the one being captured. This could be because you have duplicate users in your database. In order to fix this you need to clear your user data using the CLI delete command: + +```bash +yarn cli delete -u +``` + +See [CLI And Maintenance Commands](./cli-and-maintenance-commands.md) for the current delete flow. + +### Invalid domain name + +When encountering an invalid domain name error, this is because the URL you provided in the `SUPERTOKENS_..` value in your active environment file is incorrect. For local development that is usually `.env.local`. This could be caused by prematurely finishing the setup of your Supertokens instance. + +To fix this: + +1. Make sure to completely set up your Supertokens instance +2. Copy the exact connection URI and API key from your Supertokens dashboard +3. Verify the connection URI format matches what Supertokens provides (should include the protocol, domain, and port if applicable) +4. Ensure there are no extra spaces or characters in the environment variable values diff --git a/docs/types-and-validation.md b/docs/development/types-and-validation.md similarity index 95% rename from docs/types-and-validation.md rename to docs/development/types-and-validation.md index 152aa95ff..007fd0e85 100644 --- a/docs/types-and-validation.md +++ b/docs/development/types-and-validation.md @@ -63,7 +63,7 @@ The codebase currently uses both: - `zod/v4` - `zod/v4-mini` -Be consistent with nearby code when editing, especially inside `core` types. +Be consistent with nearby code when editing. Do not assume the repo is on a single Zod import style yet. ## When To Put A Type In `core` diff --git a/docs/google-sync-and-websocket-flow.md b/docs/features/google-sync-and-websocket-flow.md similarity index 100% rename from docs/google-sync-and-websocket-flow.md rename to docs/features/google-sync-and-websocket-flow.md diff --git a/docs/offline-storage-and-migrations.md b/docs/features/offline-storage-and-migrations.md similarity index 100% rename from docs/offline-storage-and-migrations.md rename to docs/features/offline-storage-and-migrations.md diff --git a/docs/password-auth-flow.md b/docs/features/password-auth-flow.md similarity index 97% rename from docs/password-auth-flow.md rename to docs/features/password-auth-flow.md index 2f15f255c..e5b905d31 100644 --- a/docs/password-auth-flow.md +++ b/docs/features/password-auth-flow.md @@ -241,7 +241,7 @@ Current behavior in `supertokens.middleware.ts`: The rewritten link shape comes from `buildResetPasswordLink()` and looks like: -The host/origin portion is taken from backend env (`LOCAL_WEB_URL`), and the route is always `/day` with `auth=reset` plus the token. +The host/origin portion is taken from backend env (`FRONTEND_URL`), and the route is always `/day` with `auth=reset` plus the token. - `http://localhost:9080/day?auth=reset&token=...` @@ -260,4 +260,4 @@ That lets password-auth users use Compass without blocking on Google connectivit ## Known Caveats - The rollout gate is not limited to `lastKnownEmail`; any `?auth=` URL currently enables the auth UI. -- Reset password links always target the `/day` route and require a valid `LOCAL_WEB_URL` in backend env. +- Reset password links always target the `/day` route and require a valid `FRONTEND_URL` in backend env. diff --git a/docs/recurrence-handling.md b/docs/features/recurring-events-handling.md similarity index 90% rename from docs/recurrence-handling.md rename to docs/features/recurring-events-handling.md index c42eef85a..7adfc5335 100644 --- a/docs/recurrence-handling.md +++ b/docs/features/recurring-events-handling.md @@ -82,6 +82,21 @@ Current split rule: This keeps the recurrence interpretation in the planner and the DB mutations in the executor. +## Google Series Splits + +Google "this and following" edits and deletes can split a series into multiple changes across incremental sync payloads. + +Treat these as independent updates derived from event shape, not as one ordered bundle of related payloads. + +Useful heuristics during Google sync: + +- base event with a shortened `UNTIL` usually means the original series was truncated +- a new recurring base may represent the follow-on series +- cancelled instances should be handled as instance-level deletions +- payload ordering is not reliable enough to infer user intent by itself + +This is why Compass sync logic keys off persisted state plus event properties instead of trying to reconstruct a single high-level Google UI action. + ## Someday And Provider Semantics `isSomeday` changes who is treated as the provider of record: diff --git a/docs/frontend-runtime-flow.md b/docs/frontend/frontend-runtime-flow.md similarity index 92% rename from docs/frontend-runtime-flow.md rename to docs/frontend/frontend-runtime-flow.md index f598ada07..7d2c524e7 100644 --- a/docs/frontend-runtime-flow.md +++ b/docs/frontend/frontend-runtime-flow.md @@ -234,6 +234,32 @@ Read these together for event work: - `packages/web/src/ducks/events/sagas` - `packages/web/src/store/events.ts` +## Event Flow + +The old "frontend data flow" doc is now folded into this section. + +Typical event flow: + +1. a route view, hook, or component dispatches a Redux action +2. redux-saga handles the async side effect +3. the selected repository writes locally or remotely +4. reducers and/or Elf stores update client state +5. websocket events can trigger refetch or metadata refresh later + +Important consequence: + +- event behavior is not owned by a single state system +- when debugging, inspect the action, saga, repository, and store layer together + +## Styling Systems + +The web app currently uses two styling systems in parallel: + +- longstanding `styled-components` for much of the existing UI +- Tailwind v4 utilities and semantic theme tokens from `packages/web/src/index.css` for newer or migrated surfaces + +Do not describe the frontend as Tailwind-only or styled-components-only. Follow the local pattern of the area you are editing unless the change is explicitly migrating that area. + ## Day Task Drag Handle Positioning File: diff --git a/docs/principles.md b/docs/principles.md new file mode 100644 index 000000000..f175c4c4f --- /dev/null +++ b/docs/principles.md @@ -0,0 +1,10 @@ +# Engineering Principles + +These principles mirror the repo guidance in [AGENTS.md](../AGENTS.md). + +## As simple as possible + +- Remove more than you add +- Prefer simple abstractions over complex ones +- Prefer language built-ins over libraries (example: es6 > lodash) +- Minimize dependencies diff --git a/docs/workflow-examples.md b/docs/workflow-examples.md deleted file mode 100644 index fc27cddd4..000000000 --- a/docs/workflow-examples.md +++ /dev/null @@ -1,445 +0,0 @@ -# AI Workflow Examples - -This document demonstrates Harness and Loop-style development workflows for AI agents working on Compass. - -## Table of Contents - -- [Harness Engineering Workflows](#harness-engineering-workflows) -- [Loop Methodology Examples](#loop-methodology-examples) -- [Automated Testing Workflows](#automated-testing-workflows) -- [Code Review Automation](#code-review-automation) - ---- - -## Harness Engineering Workflows - -Based on OpenAI's Harness Engineering methodology: [https://openai.com/index/harness-engineering/](https://openai.com/index/harness-engineering/) - -### Example 1: Adding a New API Endpoint - -**Goal**: Add a new endpoint to get user statistics - -**Harness Approach**: - -1. **Understand the Pattern** - - ```bash - # Study existing endpoint structure - yarn ts-node ai-tools/generate-api-docs.ts - # Review output to understand route patterns - ``` - -2. **Define the Interface First** - - ```typescript - // packages/core/src/types/user/user.stats.types.ts - import { z } from "zod"; - - export const UserStatsSchema = z.object({ - userId: z.string(), - totalEvents: z.number(), - completedTasks: z.number(), - streak: z.number(), - lastActive: z.string().datetime(), - }); - - export type UserStats = z.infer; - ``` - -3. **Create Test First** - - ```typescript - // packages/backend/src/user/__tests__/user.stats.test.ts - describe("User Statistics", () => { - it("should return user statistics", async () => { - const stats = await userService.getStats(testUserId); - expect(stats).toMatchObject({ - userId: expect.any(String), - totalEvents: expect.any(Number), - completedTasks: expect.any(Number), - }); - }); - }); - ``` - -4. **Implement the Route** - - ```typescript - // packages/backend/src/user/user.routes.config.ts - this.app - .route("/api/user/stats") - .all(verifySession()) - .get(userController.getStats); - ``` - -5. **Verify and Document** - - ```bash - # Run tests - yarn test:backend - - # Regenerate docs - yarn ts-node ai-tools/generate-api-docs.ts - - # Verify documentation includes new endpoint - cat ai-tools/api-documentation.md | grep "/api/user/stats" - ``` - -### Example 2: Refactoring with Safety Harness - -**Goal**: Extract complex date logic into utility function - -**Harness Approach**: - -1. **Add Tests for Current Behavior** - - ```typescript - // packages/core/src/util/date/__tests__/date.utils.test.ts - describe("Date Utilities", () => { - describe("existing behavior", () => { - it("should handle timezone conversions", () => { - // Test current implementation - }); - }); - }); - ``` - -2. **Run Tests to Establish Baseline** - - ```bash - yarn test:core - # All tests should pass - ``` - -3. **Refactor in Small Steps** - - ```typescript - // Extract utility function - export function convertToUserTimezone(date: Date, timezone: string): Date { - // Refactored logic here - } - ``` - -4. **Test After Each Step** - - ```bash - yarn test:core - # Ensure no regressions - ``` - -5. **Update All Call Sites** - - ```bash - # Find all usages - grep -r "oldImplementation" packages/ - - # Replace incrementally, testing after each change - ``` - ---- - -## Loop Methodology Examples - -Based on Geoffrey Huntley's Loop: [https://ghuntley.com/loop/](https://ghuntley.com/loop/) - -### Example 1: Feature Development Loop - -**Goal**: Add calendar event filtering by category - -**Loop Cycle**: - -``` -┌─────────────────────────────────────┐ -│ 1. UNDERSTAND │ -│ - Review existing filter logic │ -│ - Check Redux store structure │ -│ - Identify impact areas │ -└──────────────┬──────────────────────┘ - │ -┌──────────────▼──────────────────────┐ -│ 2. PLAN │ -│ - Define filter interface │ -│ - Map state changes needed │ -│ - Identify components to update │ -└──────────────┬──────────────────────┘ - │ -┌──────────────▼──────────────────────┐ -│ 3. IMPLEMENT (Small Increment) │ -│ - Add filter type to Redux │ -│ - Test: yarn test:web │ -└──────────────┬──────────────────────┘ - │ -┌──────────────▼──────────────────────┐ -│ 4. VERIFY │ -│ - Run tests │ -│ - Manual verification │ -│ - Code health audit │ -└──────────────┬──────────────────────┘ - │ - ├─── If issues ──────┐ - │ │ - │ ▼ -┌──────────────▼──────────────────────┐ -│ 5. NEXT LOOP OR DONE │ -│ - Add filter UI │ -│ - Repeat cycle │ -└─────────────────────────────────────┘ -``` - -**Implementation**: - -```typescript -// Loop 1: Add type definition -// packages/core/src/types/filter.types.ts -export const EventFilterSchema = z.object({ - category: z.array(z.string()).optional(), - startDate: z.string().datetime().optional(), - endDate: z.string().datetime().optional(), -}); - -// Test and commit -// git add . && git commit -m "feat(core): add event filter types" - -// Loop 2: Add Redux state -// packages/web/src/store/filter/filter.slice.ts -const filterSlice = createSlice({ - name: "filter", - initialState: { category: [] /* ... */ }, - // ...reducers -}); - -// Test and commit -// yarn test:web -// git add . && git commit -m "feat(web): add filter state to Redux" - -// Loop 3: Add UI component -// packages/web/src/views/Calendar/FilterPanel.tsx -// Test and commit - -// Loop 4: Wire up to backend -// Test and commit - -// Each loop: small, testable, verifiable increment -``` - -### Example 2: Bug Fix Loop - -**Goal**: Fix date display bug in calendar view - -**Loop Cycle**: - -```typescript -// Loop 1: REPRODUCE THE BUG -describe('Calendar Date Display', () => { - it('should display dates in user timezone', () => { - // Write failing test that reproduces the bug - const event = createTestEvent({ date: '2024-01-01T12:00:00Z' }); - render(); - expect(screen.getByText(/Jan 1, 2024/i)).toBeInTheDocument(); - // This should fail, confirming the bug - }); -}); - -// yarn test:web -// Confirm test fails as expected - -// Loop 2: FIX THE BUG (Minimal change) -// packages/web/src/views/Calendar/DateDisplay.tsx -export function formatEventDate(date: string): string { - return dayjs(date).tz(userTimezone).format('MMM D, YYYY'); - // Changed from: dayjs(date).format(...) -} - -// Loop 3: VERIFY FIX -// yarn test:web -// Test should now pass - -// Loop 4: CHECK FOR REGRESSIONS -// yarn test:core && yarn test:backend -// yarn audit:code-health - -// Loop 5: MANUAL VERIFICATION -// yarn dev:web -// Navigate to calendar and verify fix visually - -// Loop 6: COMMIT -// git add . && git commit -m "fix(web): correct date display timezone handling" -``` - ---- - -## Automated Testing Workflows - -### Workflow 1: Test-Driven Development - -```typescript -// Step 1: Write test (RED) -// packages/web/src/hooks/__tests__/useEventForm.test.ts -describe("useEventForm", () => { - it("should validate event title is not empty", () => { - const { result } = renderHook(() => useEventForm()); - - act(() => { - result.current.setTitle(""); - }); - - expect(result.current.errors.title).toBe("Title is required"); - }); -}); - -// Step 2: Run test (should fail) -// yarn test:web - -// Step 3: Implement (GREEN) -// packages/web/src/hooks/useEventForm.ts -export function useEventForm() { - const [errors, setErrors] = useState({}); - - const validateTitle = (title: string) => { - if (!title.trim()) { - setErrors((prev) => ({ ...prev, title: "Title is required" })); - return false; - } - return true; - }; - - // ... -} - -// Step 4: Run test (should pass) -// yarn test:web - -// Step 5: Refactor if needed -// Keep tests passing while improving code -``` - -### Workflow 2: Regression Test Automation - -```typescript -// Create test harness for known bugs -// ai-tools/test-harness/regression-suite.ts -import { runTest } from "./test-utils"; - -const regressionTests = [ - { - id: "BUG-123", - description: "Date picker should handle leap years", - test: async () => { - const result = await testDatePicker({ date: "2024-02-29" }); - expect(result.isValid).toBe(true); - }, - }, - { - id: "BUG-456", - description: "Drag-drop should not duplicate events", - test: async () => { - const result = await testDragDrop(); - expect(result.eventCount).toBe(1); - }, - }, -]; - -// Run all regression tests -export async function runRegressionSuite() { - for (const test of regressionTests) { - console.log(`Testing ${test.id}: ${test.description}`); - await test.test(); - } -} -``` - ---- - -## Code Review Automation - -### Workflow 1: Pre-Commit Checks - -```bash -#!/bin/bash -# .git/hooks/pre-commit (already handled by Husky) - -# Type check -echo "Running type check..." -yarn type-check || exit 1 - -# Lint -echo "Running prettier..." -yarn prettier . --write - -# Tests (quick smoke test) -echo "Running tests..." -yarn test:core || exit 1 - -echo "✅ Pre-commit checks passed" -``` - -### Workflow 2: AI-Assisted Review - -```typescript -// ai-tools/review-assistant.ts -// Checks code before PR submission - -interface ReviewChecklist { - hasTests: boolean; - typesafe: boolean; - usesAliases: boolean; - documented: boolean; - complexity: "low" | "medium" | "high"; -} - -export async function reviewCode(files: string[]): Promise { - const results: ReviewChecklist = { - hasTests: true, - typesafe: true, - usesAliases: true, - documented: true, - complexity: "low", - }; - - for (const file of files) { - // Check for test file - const testExists = fs.existsSync(file.replace(/\.tsx?$/, ".test.ts")); - results.hasTests = results.hasTests && testExists; - - // Check for relative imports - const content = fs.readFileSync(file, "utf-8"); - if (content.includes('from "../')) { - results.usesAliases = false; - } - - // Check complexity - const complexity = analyzeComplexity(content); - if (complexity > 20) { - results.complexity = "high"; - } - } - - return results; -} -``` - ---- - -## Summary - -These workflows demonstrate: - -1. **Harness Engineering**: Building safety nets through tests and tooling -2. **Loop Methodology**: Small, incremental changes with continuous verification -3. **Automation**: Scripts and tools to speed up repetitive tasks -4. **Safety**: Multiple verification steps before committing - -### Key Principles - -- **Small increments**: Each loop should be < 1 hour of work -- **Continuous testing**: Test after every change -- **Automated checks**: Use tooling to catch issues early -- **Documentation**: Keep docs in sync with code -- **Verification**: Multiple layers of safety - -### Next Steps - -1. Study these examples -2. Apply patterns to your work -3. Create your own workflow variants -4. Share improvements back to the team diff --git a/packages/backend/.env.local.example b/packages/backend/.env.local.example index f67a5c6df..6f94d4f30 100644 --- a/packages/backend/.env.local.example +++ b/packages/backend/.env.local.example @@ -58,9 +58,7 @@ SUPERTOKENS_KEY=UNIQUE_KEY_FROM_YOUR_SUPERTOKENS_ACCOUNT #################################################### # 5. Web # #################################################### -LOCAL_WEB_URL=http://localhost:9080 -# STAGING_WEB_URL=https://staging.yourdomain.com -# PROD_WEB_URL=https://app.yourdomain.com +FRONTEND_URL=http://localhost:9080 #################################################### # 6. Email - Kit (optional) # diff --git a/packages/backend/src/__tests__/backend.test.init.ts b/packages/backend/src/__tests__/backend.test.init.ts index efc25e527..95db1816f 100644 --- a/packages/backend/src/__tests__/backend.test.init.ts +++ b/packages/backend/src/__tests__/backend.test.init.ts @@ -15,4 +15,4 @@ process.env["EMAILER_API_SECRET"] = "emailerApiSecret"; process.env["EMAILER_USER_TAG_ID"] = "910111213"; process.env["TOKEN_GCAL_NOTIFICATION"] = "secretToken1"; process.env["TOKEN_COMPASS_SYNC"] = "secretToken2"; -process.env["LOCAL_WEB_URL"] = "http://localhost:9080"; +process.env["FRONTEND_URL"] = "http://localhost:9080"; diff --git a/packages/backend/src/__tests__/drivers/sync.driver.ts b/packages/backend/src/__tests__/drivers/sync.driver.ts index 7bfdb7ae5..dee7aae81 100644 --- a/packages/backend/src/__tests__/drivers/sync.driver.ts +++ b/packages/backend/src/__tests__/drivers/sync.driver.ts @@ -17,19 +17,18 @@ export class SyncDriver { const gCalendarId = defaultUser ? "test-calendar" : faker.string.uuid(); const gcal = await getGcalClient(user._id.toString()); - await Promise.all([ - updateSync(Resource_Sync.CALENDAR, user._id.toString(), gCalendarId, { - nextSyncToken: faker.string.ulid(), - }), - updateSync(Resource_Sync.EVENTS, user._id.toString(), gCalendarId, { - nextSyncToken: faker.string.ulid(), - }), - syncService.startWatchingGcalResources( - user._id.toString(), - [{ gCalendarId }, { gCalendarId: Resource_Sync.CALENDAR }], // Watch all selected calendars and calendar list - gcal, - ), - ]); + // Avoid racing multiple upserts for the same user's sync document in tests. + await updateSync(Resource_Sync.CALENDAR, user._id.toString(), gCalendarId, { + nextSyncToken: faker.string.ulid(), + }); + await updateSync(Resource_Sync.EVENTS, user._id.toString(), gCalendarId, { + nextSyncToken: faker.string.ulid(), + }); + await syncService.startWatchingGcalResources( + user._id.toString(), + [{ gCalendarId }, { gCalendarId: Resource_Sync.CALENDAR }], // Watch all selected calendars and calendar list + gcal, + ); } static async generateV0Data( diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index 385cade14..320d8c9d8 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -1,14 +1,11 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; import { Logger } from "@core/logger/winston.logger"; -import { mapCompassUserToEmailSubscriber } from "@core/mappers/subscriber/map.subscriber"; import { StringV4Schema, zObjectId } from "@core/types/type.utils"; import GoogleOAuthClient from "@backend/auth/services/google/clients/google.oauth.client"; import { determineGoogleAuthMode, parseReconnectGoogleParams, } from "@backend/auth/services/google/util/google.auth.util"; -import { ENV } from "@backend/common/constants/env.constants"; -import { isMissingUserTagId } from "@backend/common/constants/env.util"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; @@ -60,18 +57,7 @@ class GoogleAuthService { }, }); - if (isMissingUserTagId()) { - logger.warn( - "Did not tag subscriber due to missing EMAILER_ ENV value(s)", - ); - } else if (cUser.isNewUser) { - const subscriber = mapCompassUserToEmailSubscriber(cUser.user); - - await EmailService.addTagToSubscriber( - subscriber, - ENV.EMAILER_USER_TAG_ID!, - ); - } + await EmailService.tagNewUserIfEnabled(cUser.user, cUser.isNewUser); return { cUserId: cUser.user.userId }; }); diff --git a/packages/backend/src/common/constants/env.constants.ts b/packages/backend/src/common/constants/env.constants.ts index 3e4fd45bb..abf4febb5 100644 --- a/packages/backend/src/common/constants/env.constants.ts +++ b/packages/backend/src/common/constants/env.constants.ts @@ -22,7 +22,7 @@ const EnvSchema = z DB: z.string().nonempty(), EMAILER_SECRET: z.string().nonempty().optional(), EMAILER_USER_TAG_ID: z.string().nonempty().optional(), - LOCAL_WEB_URL: z.string().url(), + FRONTEND_URL: z.string().url(), MONGO_URI: z.string().nonempty(), NODE_ENV: z.nativeEnum(NodeEnv), TZ: z.enum(["Etc/UTC", "UTC"]), @@ -57,7 +57,7 @@ const processEnv = { DB: IS_DEV ? "dev_calendar" : "prod_calendar", EMAILER_SECRET: process.env["EMAILER_API_SECRET"], EMAILER_USER_TAG_ID: process.env["EMAILER_USER_TAG_ID"], - LOCAL_WEB_URL: process.env["LOCAL_WEB_URL"], + FRONTEND_URL: process.env["FRONTEND_URL"], MONGO_URI: process.env["MONGO_URI"], NODE_ENV: _nodeEnv, TZ: process.env["TZ"], diff --git a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts new file mode 100644 index 000000000..ed43e394c --- /dev/null +++ b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts @@ -0,0 +1,209 @@ +import { ObjectId } from "mongodb"; +import supertokens, { User } from "supertokens-node"; +import type { + APIInterface as EmailPasswordAPIInterface, + RecipeInterface as EmailPasswordRecipeInterface, +} from "supertokens-node/recipe/emailpassword/types"; +import Session from "supertokens-node/recipe/session"; +import type { APIInterface as SessionAPIInterface } from "supertokens-node/recipe/session/types"; +import type { + APIInterface as ThirdPartyAPIInterface, + RecipeInterface as ThirdPartyRecipeInterface, +} from "supertokens-node/recipe/thirdparty/types"; +import { NodeEnv } from "@core/constants/core.constants"; +import { Logger } from "@core/logger/winston.logger"; +import { zObjectId } from "@core/types/type.utils"; +import googleAuthService from "@backend/auth/services/google/google.auth.service"; +import { ENV } from "@backend/common/constants/env.constants"; +import { + type CreateGoogleSignInResponse, + type ThirdPartySignInUpInput, + buildResetPasswordLink, + createGoogleSignInSuccess, + ensureExternalUserIdMapping, + getFormFieldValue, +} from "@backend/common/middleware/supertokens.middleware.util"; +import mongoService from "@backend/common/services/mongo.service"; +import EmailService from "@backend/email/email.service"; +import syncService from "@backend/sync/services/sync.service"; +import userMetadataService from "@backend/user/services/user-metadata.service"; +import userService from "@backend/user/services/user.service"; + +const logger = Logger("app:supertokens.middleware"); + +type ManuallyCreateOrUpdateUserInput = Parameters< + ThirdPartyRecipeInterface["manuallyCreateOrUpdateUser"] +>[0]; + +type ThirdPartySignInUpPostFn = NonNullable< + ThirdPartyAPIInterface["signInUpPOST"] +>; + +type CreateNewRecipeUserFn = + EmailPasswordRecipeInterface["createNewRecipeUser"]; + +type SignUpPOSTFn = NonNullable; + +type SignInPOSTFn = NonNullable; + +type SessionSignOutPOSTFn = NonNullable; + +export async function createGoogleUser( + input: ManuallyCreateOrUpdateUserInput, +): Promise< + Awaited> +> { + const user = await mongoService.user.findOne( + { "google.googleId": input.thirdPartyUserId }, + { projection: { _id: 1, signedUpAt: 1, email: 1 } }, + ); + + const id = user?._id.toString() ?? new ObjectId().toString(); + const timeJoined = user?.signedUpAt?.getTime() ?? Date.now(); + const thirdParty = [ + { id: input.thirdPartyId, userId: input.thirdPartyUserId }, + ]; + + return { + status: "OK", + createdNewRecipeUser: user === null, + recipeUserId: supertokens.convertToRecipeUserId(id), + user: new User({ + emails: [user?.email ?? input.email], + id, + isPrimaryUser: false, + thirdParty, + timeJoined, + loginMethods: [ + { + recipeId: "thirdparty", + recipeUserId: id, + tenantIds: [input.tenantId], + timeJoined, + verified: input.isVerified, + email: input.email, + thirdParty: thirdParty[0], + webauthn: { credentialIds: [] }, + }, + ], + phoneNumbers: [], + tenantIds: [input.tenantId], + webauthn: { credentialIds: [] }, + }), + }; +} + +export async function handleGoogleSignInUp( + input: ThirdPartySignInUpInput, + originalSignInUpPOST: ThirdPartySignInUpPostFn, +): Promise>> { + const response = await originalSignInUpPOST(input); + const success = createGoogleSignInSuccess( + response as CreateGoogleSignInResponse, + ); + + if (success) { + await googleAuthService.handleGoogleAuth(success); + } + + return response; +} + +export async function sendPasswordResetEmail< + T extends { passwordResetLink: string; user: { email: string } }, +>(input: T, originalSendEmail: (input: T) => Promise): Promise { + const resetLink = buildResetPasswordLink( + input.passwordResetLink, + ENV.FRONTEND_URL, + ); + + if (ENV.NODE_ENV === NodeEnv.Test) { + logger.info(`Password reset link for ${input.user.email}: ${resetLink}`); + return; + } + + await originalSendEmail({ ...input, passwordResetLink: resetLink }); +} + +export async function createEmailPasswordUser( + input: Parameters[0], + originalCreateNewRecipeUser: CreateNewRecipeUserFn, +): Promise>> { + const response = await originalCreateNewRecipeUser(input); + + if (response.status !== "OK") { + return response; + } + + await ensureExternalUserIdMapping(response.recipeUserId.getAsString()); + + return response; +} + +export async function handleEmailPasswordSignUp( + input: Parameters[0], + originalSignUpPOST: SignUpPOSTFn, +): Promise>> { + const response = await originalSignUpPOST(input); + + if (response.status === "OK") { + const email = getFormFieldValue(input.formFields, "email"); + const name = getFormFieldValue(input.formFields, "name"); + const userId = response.session.getUserId(); + + if (email) { + const { user, isNewUser } = await userService.upsertUserFromAuth({ + userId, + email, + name, + }); + await EmailService.tagNewUserIfEnabled(user, isNewUser); + } + } + + return response; +} + +export async function handleEmailPasswordSignIn( + input: Parameters[0], + originalSignInPOST: SignInPOSTFn, +): Promise>> { + const response = await originalSignInPOST(input); + + if (response.status === "OK") { + const email = getFormFieldValue(input.formFields, "email"); + const userId = response.session.getUserId(); + + if (email) { + await userService.upsertUserFromAuth({ userId, email }); + } + } + + return response; +} + +export async function handleSessionSignOut( + input: Parameters[0], + originalSignOutPOST: SessionSignOutPOSTFn, +): Promise>> { + const userId = zObjectId.parse(input.session.getUserId()); + + const userSessions = await Session.getAllSessionHandlesForUser( + userId.toString(), + ); + + const lastActiveSession = userSessions.length < 2; + + const res = await originalSignOutPOST(input); + + await userMetadataService.updateUserMetadata({ + userId: userId.toString(), + data: { sync: { incrementalGCalSync: "RESTART" } }, + }); + + if (lastActiveSession) { + await syncService.stopWatches(userId.toString()); + } + + return res; +} diff --git a/packages/backend/src/common/middleware/supertokens.middleware.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.test.ts index e433fd1af..5dc31beb0 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.test.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.test.ts @@ -21,6 +21,7 @@ import { import { buildResetPasswordLink, createGoogleSignInSuccess, + ensureExternalUserIdMapping, } from "@backend/common/middleware/supertokens.middleware.util"; import syncService from "@backend/sync/services/sync.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -267,12 +268,119 @@ describe("supertokens.middleware", () => { expect(buildResetPasswordLink).toHaveBeenCalledWith( "http://localhost:1234/auth/reset-password?token=abc", - ENV.LOCAL_WEB_URL, + ENV.FRONTEND_URL, ); // In test env, sending is suppressed — originalSendEmail must not be called expect(originalSendEmail).not.toHaveBeenCalled(); }); + it("preserves EmailPassword API method context in signUpPOST and signInPOST overrides", async () => { + initSupertokens(); + + const emailPasswordConfig = (EmailPassword.init as jest.Mock).mock + .calls[0][0] as { + override: { + apis: (originalImplementation: { + signUpPOST?: (input: unknown) => Promise; + signInPOST?: (input: unknown) => Promise; + }) => { + signUpPOST: (input: unknown) => Promise; + signInPOST: (input: unknown) => Promise; + }; + }; + }; + + let signUpThis: unknown; + let signInThis: unknown; + + const originalImplementation = { + signUpPOST: jest.fn(function (this: unknown, input: unknown) { + signUpThis = this; + return Promise.resolve({ + status: "EMAIL_ALREADY_EXISTS_ERROR", + input, + }); + }), + signInPOST: jest.fn(function (this: unknown, input: unknown) { + signInThis = this; + return Promise.resolve({ + status: "WRONG_CREDENTIALS_ERROR", + input, + }); + }), + }; + + const overridden = emailPasswordConfig.override.apis( + originalImplementation, + ); + + await overridden.signUpPOST({ email: "user@example.com" }); + await overridden.signInPOST({ email: "user@example.com" }); + + expect(originalImplementation.signUpPOST).toHaveBeenCalledWith({ + email: "user@example.com", + }); + expect(originalImplementation.signInPOST).toHaveBeenCalledWith({ + email: "user@example.com", + }); + expect(signUpThis).toBe(originalImplementation); + expect(signInThis).toBe(originalImplementation); + }); + + it("keeps the original EmailPassword recipe user during createNewRecipeUser", async () => { + initSupertokens(); + + const emailPasswordConfig = (EmailPassword.init as jest.Mock).mock + .calls[0][0] as { + override: { + functions: (originalImplementation: { + createNewRecipeUser: (input: unknown) => Promise; + }) => { + createNewRecipeUser: (input: unknown) => Promise; + }; + }; + }; + + const originalUser = { + id: "recipe-user-id", + loginMethods: [ + { + recipeUserId: { + getAsString: () => "recipe-user-id", + }, + }, + ], + }; + + const responsePayload = { + status: "OK" as const, + recipeUserId: { + getAsString: () => "recipe-user-id", + }, + user: originalUser, + }; + + const originalImplementation = { + createNewRecipeUser: jest.fn().mockResolvedValue(responsePayload), + }; + + const overridden = emailPasswordConfig.override.functions( + originalImplementation, + ); + + const result = await overridden.createNewRecipeUser({ + email: "user@example.com", + }); + + expect(originalImplementation.createNewRecipeUser).toHaveBeenCalledWith({ + email: "user@example.com", + }); + expect(ensureExternalUserIdMapping).toHaveBeenCalledWith( + "recipe-user-id", + ); + expect(result).toBe(responsePayload); + }); + it("calls googleAuthService.handleGoogleAuth when ThirdParty signInUpPOST succeeds", async () => { const responsePayload = { status: "OK" }; const successPayload = { providerUser: { id: "u1" } }; diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index b4e9cbd04..831931d20 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -1,10 +1,5 @@ import cors from "cors"; -import { ObjectId } from "mongodb"; -import supertokens, { - default as SuperTokens, - User, - getUser, -} from "supertokens-node"; +import SuperTokens from "supertokens-node"; import AccountLinking from "supertokens-node/recipe/accountlinking"; import Dashboard from "supertokens-node/recipe/dashboard"; import EmailPassword from "supertokens-node/recipe/emailpassword"; @@ -18,24 +13,16 @@ import { } from "@core/constants/core.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; -import { Logger } from "@core/logger/winston.logger"; -import { zObjectId } from "@core/types/type.utils"; -import googleAuthService from "@backend/auth/services/google/google.auth.service"; import { ENV } from "@backend/common/constants/env.constants"; import { - type CreateGoogleSignInResponse, - type ThirdPartySignInUpInput, - buildResetPasswordLink, - createGoogleSignInSuccess, - ensureExternalUserIdMapping, - getFormFieldValue, -} from "@backend/common/middleware/supertokens.middleware.util"; -import mongoService from "@backend/common/services/mongo.service"; -import syncService from "@backend/sync/services/sync.service"; -import userMetadataService from "@backend/user/services/user-metadata.service"; -import userService from "@backend/user/services/user.service"; - -const logger = Logger("app:supertokens.middleware"); + createEmailPasswordUser, + createGoogleUser, + handleEmailPasswordSignIn, + handleEmailPasswordSignUp, + handleGoogleSignInUp, + handleSessionSignOut, + sendPasswordResetEmail, +} from "@backend/common/middleware/supertokens.middleware.handlers"; export const initSupertokens = () => { SuperTokens.init({ @@ -87,54 +74,17 @@ export const initSupertokens = () => { return { ...originalImplementation, async manuallyCreateOrUpdateUser(input) { - const user = await mongoService.user.findOne( - { "google.googleId": input.thirdPartyUserId }, - { projection: { _id: 1, signedUpAt: 1, email: 1 } }, - ); - - const id = user?._id.toString() ?? new ObjectId().toString(); - const timeJoined = user?.signedUpAt?.getTime() ?? Date.now(); - const thirdParty = [ - { id: input.thirdPartyId, userId: input.thirdPartyUserId }, - ]; - - return { - status: "OK", - createdNewRecipeUser: user === null, - recipeUserId: supertokens.convertToRecipeUserId(id), - timeJoined, - thirdParty, - user: new User({ - emails: [user?.email ?? input.email], - id, - isPrimaryUser: false, - thirdParty, - timeJoined, - loginMethods: [ - { - recipeId: "thirdparty", - recipeUserId: id, - tenantIds: [input.tenantId], - timeJoined, - verified: input.isVerified, - email: input.email, - thirdParty: thirdParty[0], - webauthn: { credentialIds: [] }, - }, - ], - phoneNumbers: [], - tenantIds: [input.tenantId], - webauthn: { credentialIds: [] }, - }), - }; + return createGoogleUser(input); }, }; }, apis(originalImplementation) { return { ...originalImplementation, - async signInUpPOST(input: ThirdPartySignInUpInput) { - if (!originalImplementation.signInUpPOST) { + async signInUpPOST(input) { + const signInUpPOST = originalImplementation.signInUpPOST; + + if (!signInUpPOST) { throw new BaseError( "signInUpPOST not implemented", "signInUpPOST not implemented", @@ -143,17 +93,10 @@ export const initSupertokens = () => { ); } - const response = - await originalImplementation.signInUpPOST(input); - const success = createGoogleSignInSuccess( - response as CreateGoogleSignInResponse, + return handleGoogleSignInUp( + input, + signInUpPOST.bind(originalImplementation), ); - - if (success) { - await googleAuthService.handleGoogleAuth(success); - } - - return response; }, }; }, @@ -176,24 +119,10 @@ export const initSupertokens = () => { emailDelivery: { override: (originalImplementation) => ({ ...originalImplementation, - sendEmail: async (input) => { - const resetLink = buildResetPasswordLink( - input.passwordResetLink, - ENV.LOCAL_WEB_URL, - ); - - if (ENV.NODE_ENV === "test") { - logger.info( - `Password reset link for ${input.user.email}: ${resetLink}`, - ); - return; - } - - await originalImplementation.sendEmail({ - ...input, - passwordResetLink: resetLink, - }); - }, + sendEmail: (input) => + sendPasswordResetEmail(input, (emailInput) => + originalImplementation.sendEmail(emailInput), + ), }), }, override: { @@ -201,25 +130,9 @@ export const initSupertokens = () => { return { ...originalImplementation, async createNewRecipeUser(input) { - const response = - await originalImplementation.createNewRecipeUser(input); - - if (response.status !== "OK") { - return response; - } - - await ensureExternalUserIdMapping( - response.recipeUserId.getAsString(), - ); - - const updatedUser = await getUser( - response.recipeUserId.getAsString(), + return createEmailPasswordUser(input, (recipeInput) => + originalImplementation.createNewRecipeUser(recipeInput), ); - - return { - ...response, - user: updatedUser ?? response.user, - }; }, }; }, @@ -227,7 +140,9 @@ export const initSupertokens = () => { return { ...originalImplementation, async signUpPOST(input) { - if (!originalImplementation.signUpPOST) { + const signUpPOST = originalImplementation.signUpPOST; + + if (!signUpPOST) { throw new BaseError( "signUpPOST not implemented", "signUpPOST not implemented", @@ -236,26 +151,15 @@ export const initSupertokens = () => { ); } - const response = await originalImplementation.signUpPOST(input); - - if (response.status === "OK") { - const email = getFormFieldValue(input.formFields, "email"); - const name = getFormFieldValue(input.formFields, "name"); - const userId = response.session.getUserId(); - - if (email) { - await userService.upsertUserFromAuth({ - userId, - email, - name, - }); - } - } - - return response; + return handleEmailPasswordSignUp( + input, + signUpPOST.bind(originalImplementation), + ); }, async signInPOST(input) { - if (!originalImplementation.signInPOST) { + const signInPOST = originalImplementation.signInPOST; + + if (!signInPOST) { throw new BaseError( "signInPOST not implemented", "signInPOST not implemented", @@ -264,18 +168,10 @@ export const initSupertokens = () => { ); } - const response = await originalImplementation.signInPOST(input); - - if (response.status === "OK") { - const email = getFormFieldValue(input.formFields, "email"); - const userId = response.session.getUserId(); - - if (email) { - await userService.upsertUserFromAuth({ userId, email }); - } - } - - return response; + return handleEmailPasswordSignIn( + input, + signInPOST.bind(originalImplementation), + ); }, }; }, @@ -288,7 +184,9 @@ export const initSupertokens = () => { return { ...originalImplementation, async signOutPOST(input) { - if (!originalImplementation.signOutPOST) { + const signOutPOST = originalImplementation.signOutPOST; + + if (!signOutPOST) { throw new BaseError( "signOutPOST not implemented", "signOutPOST not implemented", @@ -296,26 +194,11 @@ export const initSupertokens = () => { true, ); } - const userId = zObjectId.parse(input.session.getUserId()); - const userSessions = await Session.getAllSessionHandlesForUser( - userId.toString(), + return handleSessionSignOut( + input, + signOutPOST.bind(originalImplementation), ); - - const lastActiveSession = userSessions.length < 2; - - const res = await originalImplementation.signOutPOST(input); - - await userMetadataService.updateUserMetadata({ - userId: userId.toString(), - data: { sync: { incrementalGCalSync: "RESTART" } }, - }); - - if (lastActiveSession) { - await syncService.stopWatches(userId.toString()); - } - - return res; }, }; }, diff --git a/packages/backend/src/email/email.service.ts b/packages/backend/src/email/email.service.ts index 52a61abc3..70b0fa767 100644 --- a/packages/backend/src/email/email.service.ts +++ b/packages/backend/src/email/email.service.ts @@ -1,10 +1,13 @@ import axios from "axios"; import { Logger } from "@core/logger/winston.logger"; +import { mapCompassUserToEmailSubscriber } from "@core/mappers/subscriber/map.subscriber"; import { type Subscriber, SubscriberSchema, } from "@core/types/email/email.types"; +import { type Schema_User } from "@core/types/user.types"; import { ENV } from "@backend/common/constants/env.constants"; +import { isMissingUserTagId } from "@backend/common/constants/env.util"; import { EmailerError } from "@backend/common/errors/emailer/emailer.errors"; import { error } from "@backend/common/errors/handlers/error.handler"; import { @@ -32,6 +35,21 @@ class EmailService { } } + static async tagNewUserIfEnabled( + user: Schema_User, + isNewUser: boolean, + ): Promise { + if (isMissingUserTagId()) { + logger.warn( + "Did not tag subscriber due to missing EMAILER_ ENV value(s)", + ); + return; + } + if (!isNewUser) return; + const subscriber = mapCompassUserToEmailSubscriber(user); + await EmailService.addTagToSubscriber(subscriber, ENV.EMAILER_USER_TAG_ID!); + } + static async addTagToSubscriber( subscriber: Subscriber, tagId: string, diff --git a/packages/core/src/mappers/map.event.ts b/packages/core/src/mappers/map.event.ts index ccb712d61..077029129 100644 --- a/packages/core/src/mappers/map.event.ts +++ b/packages/core/src/mappers/map.event.ts @@ -256,10 +256,6 @@ const getPriority = (gEvent: gSchema$Event): Priorities => { ) { return priority as Priorities; } - // Found a priority that doesn't match enum, set to unassigned - console.warn( - `Found a priority that doesn't match enum: ${priority}. Using ${Priorities.UNASSIGNED} instead. (gEvent.id: ${gEvent.id})`, - ); return Priorities.UNASSIGNED; } return Priorities.UNASSIGNED; diff --git a/packages/core/src/util/event/compass.event.rrule.test.ts b/packages/core/src/util/event/compass.event.rrule.test.ts index fcc74334c..77d4f4c52 100644 --- a/packages/core/src/util/event/compass.event.rrule.test.ts +++ b/packages/core/src/util/event/compass.event.rrule.test.ts @@ -24,7 +24,10 @@ import { describe("CompassEventRRule: ", () => { it(`should return the correct number of events based on rrule count`, () => { - const count = faker.number.int({ min: 1, max: GCAL_MAX_RECURRENCES }); + const count = faker.number.int({ + min: 1, + max: GCAL_MAX_RECURRENCES - 1, + }); const rruleString = `RRULE:FREQ=DAILY;COUNT=${count}`; const baseEvent = createMockBaseEvent({ @@ -113,7 +116,7 @@ describe("CompassEventRRule: ", () => { describe("diffOptions", () => { it("should return the differences between two rrule options", () => { - const until = dayjs(); + const until = dayjs("2026-01-15T12:34:56Z"); const untilRule = `UNTIL=${until.toRRuleDTSTARTString()}`; const rule = [`RRULE:FREQ=DAILY;COUNT=10;BYDAY=MO,WE,FR;${untilRule}`]; const _baseEvent = createMockBaseEvent({ recurrence: { rule } }); diff --git a/packages/core/src/util/event/event.util.test.ts b/packages/core/src/util/event/event.util.test.ts index 606e31dc2..818e8acd9 100644 --- a/packages/core/src/util/event/event.util.test.ts +++ b/packages/core/src/util/event/event.util.test.ts @@ -30,7 +30,7 @@ describe("categorizeEvents", () => { describe("diffRRuleOptions", () => { it("should return the differences between two rrule options", () => { - const until = dayjs(); + const until = dayjs("2026-01-15T12:34:56Z"); const untilRule = `UNTIL=${until.toRRuleDTSTARTString()}`; const rule = `RRULE:FREQ=DAILY;COUNT=10;BYDAY=MO,WE,FR;${untilRule}`; const rrule = rrulestr(rule); diff --git a/packages/scripts/src/commands/delete.ts b/packages/scripts/src/commands/delete.ts index e10065bee..581c85f54 100644 --- a/packages/scripts/src/commands/delete.ts +++ b/packages/scripts/src/commands/delete.ts @@ -1,7 +1,6 @@ import pkg from "inquirer"; import open, { apps } from "open"; -import { CLI_ENV, ENVIRONMENT } from "@scripts/common/cli.constants"; -import { type Environment_Cli } from "@scripts/common/cli.types"; +import { CLI_ENV } from "@scripts/common/cli.constants"; import { log } from "@scripts/common/cli.utils"; import mongoService from "@backend/common/services/mongo.service"; import { findCompassUsersBy } from "@backend/user/queries/user.queries"; @@ -34,31 +33,8 @@ const getBrowserApp = (): { name: string | readonly string[] } | undefined => { return { name: browserApp }; }; -/** - * Gets the appropriate cleanup URL based on NODE_ENV - */ const getCleanupUrl = (): string => { - const env = process.env["NODE_ENV"] as Environment_Cli; - - if (env === ENVIRONMENT.PROD) { - if (!CLI_ENV.PROD_WEB_URL) { - throw new Error( - 'Unable to determine cleanup URL. NODE_ENV="production" but PROD_WEB_URL is not set.', - ); - } - return `${CLI_ENV.PROD_WEB_URL}/cleanup`; - } - - if (env === ENVIRONMENT.STAG) { - if (!CLI_ENV.STAGING_WEB_URL) { - throw new Error( - 'Unable to determine cleanup URL. NODE_ENV="staging" but STAGING_WEB_URL is not set.', - ); - } - return `${CLI_ENV.STAGING_WEB_URL}/cleanup`; - } - - return `${CLI_ENV.LOCAL_WEB_URL}/cleanup`; + return `${CLI_ENV.FRONTEND_URL}/cleanup`; }; /** @@ -141,7 +117,11 @@ export const deleteCompassDataForMatchingUsers = async (user: string) => { const totalSummary: Summary_Delete[] = []; for (const user of users) { const userId = user?._id.toString(); - const summary = await userService.deleteCompassDataForUser(userId); + const gcalAccess = !!user.google?.gRefreshToken; + const summary = await userService.deleteCompassDataForUser( + userId, + gcalAccess, + ); totalSummary.push(summary); } diff --git a/packages/scripts/src/common/cli.constants.ts b/packages/scripts/src/common/cli.constants.ts index 39f5aa4b4..e2221711b 100644 --- a/packages/scripts/src/common/cli.constants.ts +++ b/packages/scripts/src/common/cli.constants.ts @@ -1,7 +1,5 @@ type CliEnv = { - LOCAL_WEB_URL: string; - STAGING_WEB_URL: string | undefined; - PROD_WEB_URL: string | undefined; + FRONTEND_URL: string; DEV_BROWSER: string | undefined; }; @@ -23,8 +21,6 @@ export const ENVIRONMENT = { }; export const CLI_ENV: CliEnv = { - LOCAL_WEB_URL: process.env["LOCAL_WEB_URL"] || `http://localhost:9080`, - STAGING_WEB_URL: process.env["STAGING_WEB_URL"], - PROD_WEB_URL: process.env["PROD_WEB_URL"], + FRONTEND_URL: process.env["FRONTEND_URL"] || `http://localhost:9080`, DEV_BROWSER: process.env["DEV_BROWSER"], }; diff --git a/packages/scripts/src/common/cli.utils.ts b/packages/scripts/src/common/cli.utils.ts index ea86ffecd..56096131b 100644 --- a/packages/scripts/src/common/cli.utils.ts +++ b/packages/scripts/src/common/cli.utils.ts @@ -22,7 +22,7 @@ export const getApiBaseUrl = async ( return baseUrl; } - const domain = await getDomainAnswer(category); + const domain = await getDomainAnswer(); return `https://${domain}/api`; }; @@ -48,28 +48,24 @@ export const getClientId = async (environment: Environment_Cli) => { throw Error("Invalid destination"); }; -const getDomainAnswer = async (env: string) => { - const isLocal = env === "local"; - const isStaging = env === "staging"; +const getDomainAnswer = async () => { + const { hostname, host } = new URL(CLI_ENV.FRONTEND_URL); - if (isStaging && CLI_ENV.STAGING_WEB_URL !== undefined) { - return new URL(CLI_ENV.STAGING_WEB_URL).host; - } - - if (!isLocal && !isStaging && CLI_ENV.PROD_WEB_URL !== undefined) { - return new URL(CLI_ENV.PROD_WEB_URL).host; + if (hostname !== "localhost") { + return host; } const q = `Enter the domain of the VM that will be used. Do not include 'https://', just the domain. Example: app.yourdomain.com + Tip: set FRONTEND_URL in .env to skip this step. + Type here:`; return prompt([{ type: "input", name: "answer", message: q }]) .then((a: { answer: string }) => { - log.info(`\tUsing: ${a.answer}. - Save this value in .env to skip this step next time`); + log.info(`\tUsing: ${a.answer}`); return a.answer; }) .catch((e) => { diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 8a342bb10..960d8a49d 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -1,9 +1,16 @@ -import { type ReactElement, type ReactNode } from "react"; -import { MemoryRouter } from "react-router-dom"; +import { type ReactElement, type ReactNode, useLayoutEffect } from "react"; +import { + MemoryRouter, + Outlet, + RouterProvider, + createMemoryRouter, + useLocation, +} from "react-router-dom"; import EmailPassword from "supertokens-web-js/recipe/emailpassword"; import "@testing-library/jest-dom"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { loadDayData, loadTodayData } from "@web/routers/loaders"; import { AccountIcon } from "./AccountIcon"; import { AuthModal } from "./AuthModal"; import { AuthModalProvider } from "./AuthModalProvider"; @@ -126,6 +133,63 @@ async function flushEffects() { await Promise.resolve(); } +const RouteLocationMirror = ({ children }: { children: ReactNode }) => { + const location = useLocation(); + + useLayoutEffect(() => { + mockWindowLocation( + `${location.pathname}${location.search}${location.hash}`, + ); + }, [location]); + + return <>{children}; +}; + +const DayRedirectShell = () => ( + + + + + + +); + +const renderWithDayRedirectRoute = (initialRoute: string) => { + mockWindowLocation(initialRoute); + + const router = createMemoryRouter( + [ + { + path: "/day", + Component: DayRedirectShell, + children: [ + { + index: true, + loader: loadDayData, + }, + { + path: ":dateString", + element:
Day route loaded
, + }, + ], + }, + ], + { + initialEntries: [initialRoute], + future: { + v7_relativeSplatPath: true, + }, + }, + ); + + return { + router, + ...render( + , + ), + }; +}; + describe("AuthModal", () => { beforeEach(() => { jest.clearAllMocks(); @@ -860,6 +924,25 @@ describe("URL Parameter Support", () => { }); }); + it("opens reset password after the /day redirect preserves auth params", async () => { + const { dateString } = loadTodayData(); + + renderWithDayRedirectRoute("/day?auth=reset&token=reset-token"); + + await waitFor(() => { + expect(screen.getByText("Day route loaded")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /set new password/i }), + ).toBeInTheDocument(); + }); + + expect(mockReplaceState).toHaveBeenCalledWith( + null, + "", + `/day/${dateString}?token=reset-token`, + ); + }); + it("submits reset password with the initial token after the URL changes", async () => { const user = userEvent.setup(); mockWindowLocation("/day?auth=reset&token=reset-token"); @@ -882,10 +965,50 @@ describe("URL Parameter Support", () => { }); }); + expect(mockReplaceState).toHaveBeenLastCalledWith(undefined, "", "/day"); + expect(screen.getByRole("status")).toHaveTextContent( + "Password reset successful. Log in with your new password.", + ); + expect( + screen.getByRole("heading", { name: /hey, welcome back/i }), + ).toBeInTheDocument(); + expect(mockCompleteAuthentication).not.toHaveBeenCalled(); expect( mockEmailPassword.getResetPasswordTokenFromURL, ).not.toHaveBeenCalled(); }); + + it("switches to signUp (not back to loginAfterReset) when Sign up is clicked after reset", async () => { + const user = userEvent.setup(); + mockWindowLocation("/day?auth=reset&token=reset-token"); + mockEmailPassword.submitNewPassword.mockResolvedValue({ + status: "OK", + } as never); + renderWithProviders(
, "/day?auth=reset&token=reset-token"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /set new password/i }), + ).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/new password/i), "newpassword123"); + await user.click(screen.getByRole("button", { name: /set new password/i })); + + await waitFor(() => { + expect(screen.getByRole("status")).toHaveTextContent( + "Password reset successful. Log in with your new password.", + ); + }); + + await user.click(screen.getByRole("button", { name: /^sign up$/i })); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /nice to meet you/i }), + ).toBeInTheDocument(); + }); + }); }); describe("AccountIcon", () => { diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index c23f01aed..9b3b8b40b 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -40,6 +40,8 @@ export const AuthModal: FC = () => { const { isOpen, currentView, openModal, closeModal, setView } = useAuthModal(); const googleAuth = useGoogleAuth(); + const isLoginView = + currentView === "login" || currentView === "loginAfterReset"; const resetPasswordToken = useRef(getInitialResetPasswordToken()).current; const { isSubmitting, @@ -68,12 +70,12 @@ export const AuthModal: FC = () => { }, [currentView]); const handleSwitchAuth = useCallback( - () => setView(currentView === "login" ? "signUp" : "login"), + () => setView(currentView === "signUp" ? "login" : "signUp"), [currentView, setView], ); const handleGoogleSignIn = useCallback(() => { - googleAuth.login(); + void googleAuth.login(); closeModal(); }, [googleAuth, closeModal]); @@ -93,11 +95,10 @@ export const AuthModal: FC = () => { return null; } - const showAuthSwitch = currentView === "login" || currentView === "signUp"; + const showAuthSwitch = isLoginView || currentView === "signUp"; const showGoogleAuth = currentView !== "resetPassword"; const showSubmitError = - submitError !== null && - (currentView === "login" || currentView === "signUp"); + submitError !== null && (isLoginView || currentView === "signUp"); const trimmedName = signUpName.trim(); const title = currentView === "forgotPassword" @@ -121,11 +122,16 @@ export const AuthModal: FC = () => { isSubmitting={isSubmitting} /> )} - {currentView === "login" && ( + {isLoginView && ( )} {currentView === "forgotPassword" && ( @@ -161,7 +167,7 @@ export const AuthModal: FC = () => { variant="outline" onClick={handleSwitchAuth} > - {currentView === "login" ? "Sign up" : "Log in"} + {isLoginView ? "Sign up" : "Log in"} )} diff --git a/packages/web/src/components/AuthModal/forms/LogInForm.tsx b/packages/web/src/components/AuthModal/forms/LogInForm.tsx index 126d4d448..9da4323bf 100644 --- a/packages/web/src/components/AuthModal/forms/LogInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/LogInForm.tsx @@ -14,6 +14,8 @@ interface SignInFormProps { onForgotPassword: () => void; /** Whether form submission is in progress */ isSubmitting?: boolean; + /** Optional status message shown above the form */ + statusMessage?: string | null; } /** @@ -25,6 +27,7 @@ export const LogInForm: FC = ({ onSubmit, onForgotPassword, isSubmitting, + statusMessage, }) => { const form = useZodForm({ schema: LogInSchema, @@ -34,6 +37,12 @@ export const LogInForm: FC = ({ return (
+ {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} + { ); }; +const renderSignInFormWithStatus = (statusMessage: string) => { + render( + , + ); +}; + describe("LogInForm", () => { beforeEach(() => { jest.clearAllMocks(); @@ -174,4 +184,16 @@ describe("LogInForm", () => { expect(mockOnForgotPassword).toHaveBeenCalled(); }); }); + + describe("status message", () => { + it("shows a status message above the form when provided", () => { + renderSignInFormWithStatus( + "Password reset successful. Log in with your new password.", + ); + + expect(screen.getByRole("status")).toHaveTextContent( + "Password reset successful. Log in with your new password.", + ); + }); + }); }); diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts index f1f360cc9..a0cd2e63e 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts @@ -25,6 +25,18 @@ function getResetPasswordQueryParams(): z.infer< return parsed.success ? parsed.data : {}; } +function updateCurrentUrlSearchParams( + updateSearchParams: (searchParams: URLSearchParams) => void, +): void { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + updateSearchParams(url.searchParams); + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + + window.history.replaceState(window.history.state, "", nextUrl); +} + interface UseAuthFormHandlersOptions { currentView: AuthView; closeModal: () => void; @@ -32,12 +44,21 @@ interface UseAuthFormHandlersOptions { setView: (view: AuthView) => void; } +export interface UseAuthFormHandlersResult { + isSubmitting: boolean; + submitError: string | null; + handleSignUp: (data: SignUpFormData) => Promise; + handleLogin: (data: LogInFormData) => Promise; + handleForgotPassword: (data: ForgotPasswordFormData) => Promise; + handleResetPassword: (data: ResetPasswordFormData) => Promise; +} + export function useAuthFormHandlers({ currentView, closeModal, resetPasswordToken, setView, -}: UseAuthFormHandlersOptions) { +}: UseAuthFormHandlersOptions): UseAuthFormHandlersResult { const completeAuthentication = useCompleteAuthentication(); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -174,10 +195,10 @@ export function useAuthFormHandlers({ // We keep the first token we saw (from props or URL) so the flow still works even if the URL changes. const token = initialResetPasswordToken; - if (token && typeof window !== "undefined") { - const url = new URL(window.location.href); - url.searchParams.set("token", token); - window.history.replaceState(window.history.state, "", url.toString()); + if (token) { + updateCurrentUrlSearchParams((searchParams) => { + searchParams.set("token", token); + }); } const response = await EmailPassword.submitNewPassword({ formFields: [{ id: "password", value: data.password }], @@ -185,7 +206,10 @@ export function useAuthFormHandlers({ switch (response.status) { case "OK": - setView("login"); + updateCurrentUrlSearchParams((searchParams) => { + searchParams.delete("token"); + }); + setView("loginAfterReset"); return; case "FIELD_ERROR": setSubmitError( diff --git a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts index 8a51ccf62..1fca97cd2 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts @@ -6,7 +6,12 @@ import { useState, } from "react"; -export type AuthView = "login" | "signUp" | "forgotPassword" | "resetPassword"; +export type AuthView = + | "login" + | "loginAfterReset" + | "signUp" + | "forgotPassword" + | "resetPassword"; interface AuthModalContextValue { isOpen: boolean; diff --git a/packages/web/src/components/AuthModal/hooks/useZodForm.ts b/packages/web/src/components/AuthModal/hooks/useZodForm.ts index fddf419e8..f9c53bbbc 100644 --- a/packages/web/src/components/AuthModal/hooks/useZodForm.ts +++ b/packages/web/src/components/AuthModal/hooks/useZodForm.ts @@ -35,7 +35,7 @@ export interface UseZodFormReturn> { field: keyof TValues & string, ) => (e: ChangeEvent) => void; handleBlur: (field: keyof TValues & string) => () => void; - handleSubmit: (e: FormEvent) => Promise; + handleSubmit: (e: FormEvent) => void; isValid: boolean; } @@ -104,7 +104,7 @@ export function useZodForm>({ ); const handleSubmit = useCallback( - async (e: FormEvent) => { + (e: FormEvent): void => { e.preventDefault(); const allTouched = Object.keys(initialValues).reduce( @@ -115,13 +115,18 @@ export function useZodForm>({ const result = schema.safeParse(values); if (result.success) { - try { - await onSubmit(result.data); - } catch (error) { - // Error is handled by the onSubmit callback - // Swallow the error to prevent unhandled promise rejection - // since React form handlers don't await the returned promise - } + void (async () => { + try { + await onSubmit(result.data); + } catch (error) { + // Error is handled by the onSubmit callback + // Swallow the error to prevent unhandled promise rejection + // since React form handlers don't await the returned promise + if (process.env.NODE_ENV === "development") { + console.error(error); + } + } + })(); } else { setErrors( getFieldErrors(result.error) as Partial< diff --git a/packages/web/src/routers/loaders.test.ts b/packages/web/src/routers/loaders.test.ts index 0e132075f..548728b33 100644 --- a/packages/web/src/routers/loaders.test.ts +++ b/packages/web/src/routers/loaders.test.ts @@ -1,14 +1,49 @@ +import { type LoaderFunctionArgs } from "react-router-dom"; import { ROOT_ROUTES } from "@web/common/constants/routes"; -import { loadRootData, loadTodayData } from "@web/routers/loaders"; +import { loadDayData, loadRootData, loadTodayData } from "@web/routers/loaders"; + +function createLoaderArgs(url: string): LoaderFunctionArgs { + return { + request: new Request(url), + params: {}, + context: undefined, + }; +} describe("loadRootData", () => { it("redirects root route to day route with today's date", async () => { const { dateString } = loadTodayData(); - const response = await loadRootData(); + const response = await loadRootData(createLoaderArgs("http://localhost/")); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe( `${ROOT_ROUTES.DAY}/${dateString}`, ); }); + + it("preserves auth query params when redirecting to today's date", async () => { + const { dateString } = loadTodayData(); + const response = await loadRootData( + createLoaderArgs("http://localhost/?auth=login"), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe( + `${ROOT_ROUTES.DAY}/${dateString}?auth=login`, + ); + }); +}); + +describe("loadDayData", () => { + it("preserves auth query params when redirecting to the dated route", async () => { + const { dateString } = loadTodayData(); + const response = await loadDayData( + createLoaderArgs("http://localhost/day?auth=reset&token=abc"), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe( + `${ROOT_ROUTES.DAY}/${dateString}?auth=reset&token=abc`, + ); + }); }); diff --git a/packages/web/src/routers/loaders.ts b/packages/web/src/routers/loaders.ts index 5d3442914..966c14ca5 100644 --- a/packages/web/src/routers/loaders.ts +++ b/packages/web/src/routers/loaders.ts @@ -29,14 +29,21 @@ export function loadTodayData(): DayLoaderData { return { dateInView, dateString: dateInView.format(dateFormat) }; } -export async function loadDayData() { +function buildTodayRedirectUrl(request: Request): string { const { dateString } = loadTodayData(); + const url = new URL(request.url); - return redirect(`${ROOT_ROUTES.DAY}/${dateString}`); + return `${ROOT_ROUTES.DAY}/${dateString}${url.search}`; } -export async function loadRootData() { - return loadDayData(); +export function loadDayData({ + request, +}: LoaderFunctionArgs): Response { + return redirect(buildTodayRedirectUrl(request)); +} + +export function loadRootData(args: LoaderFunctionArgs): Response { + return loadDayData(args); } export async function loadSpecificDayData({