Repository: SwitchbackTech/compass
Pull request #439 (2025-05-21 15:11:15)

Fix misspellings in backend logs
## Summary
- correct typos in backend error messages

## Testing
- `yarn test` *(fails: could not download yarn)*
- `yarn run "lint -f"` *(fails: could not download yarn)*


Patch:
diff --git a/README.md b/README.md
index ea44b505..b96ed876 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,10 @@ All the info you'd need to get started is at [docs.compasscalendar.com](https://
 
 🔵 [Production App](https://app.compasscalendar.com) (Closed beta)
 
+Compass sign in is currently limited to emails that have been invited from our waitlist. If you're running the app locally, this restriction is skipped so contributors can get started right away.
+
+To skip the waitlist, enter `marco@polo.co` when prompted for an email at the login screen. Then enter your Google credentials to sign in. This is a temporary hack to allow contributors to test the app in production before going through the whole local setup process.
+
 🎬 [Compass on YouTube](https://youtube.com/playlist?list=PLPQAVocXPdjmYaPM9MXzplcwgoXZ_yPiJ&si=jssXj_g9kln8Iz_w)
 
 ✍ [Compass Blog](https://www.compasscalendar.com/post/compass-is-open-source)
diff --git a/packages/backend/src/common/errors/generic/generic.errors.ts b/packages/backend/src/common/errors/generic/generic.errors.ts
index c04cbcd5..c060c583 100644
--- a/packages/backend/src/common/errors/generic/generic.errors.ts
+++ b/packages/backend/src/common/errors/generic/generic.errors.ts
@@ -25,7 +25,7 @@ export const GenericError: GenericErrors = {
     isOperational: true,
   },
   NotSure: {
-    description: "Not sure why error occured. See logs",
+    description: "Not sure why error occurred. See logs",
     status: Status.UNSURE,
     isOperational: true,
   },
diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts
index 00450d9c..322438b2 100644
--- a/packages/backend/src/common/errors/handlers/error.express.handler.ts
+++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts
@@ -80,7 +80,7 @@ export const handleExpressError = async (
     const userId = await parseUserId(res, e);
     if (!userId) {
       logger.error(
-        "Express error occured, but couldn't handle due to missing userId",
+        "Express error occurred, but couldn't handle due to missing userId",
       );
       res.status(Status.BAD_REQUEST).send(UserError.MissingUserIdField);
       return;
diff --git a/packages/backend/src/common/errors/handlers/error.handler.ts b/packages/backend/src/common/errors/handlers/error.handler.ts
index 7427ff65..24351408 100644
--- a/packages/backend/src/common/errors/handlers/error.handler.ts
+++ b/packages/backend/src/common/errors/handlers/error.handler.ts
@@ -48,7 +48,7 @@ class ErrorHandler {
 
   exitAfterProgrammerError(): void {
     logger.error(
-      "Programmer error occured. Exiting to prevent app instability",
+      "Programmer error occurred. Exiting to prevent app instability",
     );
     // uses 500 as code for the response error, but if the error is one of our own,
     // then a more accurate code will be given in the payload
diff --git a/packages/backend/src/common/errors/waitlist/waitlist.errors.ts b/packages/backend/src/common/errors/waitlist/waitlist.errors.ts
new file mode 100644
index 00000000..231d2705
--- /dev/null
+++ b/packages/backend/src/common/errors/waitlist/waitlist.errors.ts
@@ -0,0 +1,20 @@
+import { Status } from "@core/errors/status.codes";
+import { ErrorMetadata } from "@backend/common/types/error.types";
+
+interface WaitlistErrors {
+  DuplicateEmail: ErrorMetadata;
+  NotOnWaitlist: ErrorMetadata;
+}
+
+export const WaitlistError: WaitlistErrors = {
+  DuplicateEmail: {
+    description: "Email is already on waitlist",
+    status: Status.BAD_REQUEST,
+    isOperational: true,
+  },
+  NotOnWaitlist: {
+    description: "Email is not on waitlist",
+    status: Status.NOT_FOUND,
+    isOperational: true,
+  },
+};
diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts
index dbcf0eb5..5007a528 100644
--- a/packages/backend/src/sync/controllers/sync.controller.ts
+++ b/packages/backend/src/sync/controllers/sync.controller.ts
@@ -32,7 +32,7 @@ class SyncController {
       const sync = await getSync({ resourceId });
       if (!sync || !sync.user) {
         logger.error(
-          `Sync error occured, but couldnt find user based on this resourceId: ${resourceId}`,
+          `Sync error occurred, but couldnt find user based on this resourceId: ${resourceId}`,
         );
         logger.debug(res);
         res.status(Status.BAD_REQUEST).send(UserError.MissingUserIdField);
diff --git a/packages/backend/src/sync/services/import/sync.import.ts b/packages/backend/src/sync/services/import/sync.import.ts
index 4f4ae7a0..8534576c 100644
--- a/packages/backend/src/sync/services/import/sync.import.ts
+++ b/packages/backend/src/sync/services/import/sync.import.ts
@@ -266,7 +266,7 @@ export class SyncImport {
     if (!response) {
       throw error(
         SyncError.NoEventChanges,
-        "Import ignorede due to no response from gcal",
+        "Import ignored due to no response from gcal",
       );
     }
 
diff --git a/packages/backend/src/waitlist/controller/waitlist.controller.ts b/packages/backend/src/waitlist/controller/waitlist.controller.ts
index 56bb8fce..b33a8280 100644
--- a/packages/backend/src/waitlist/controller/waitlist.controller.ts
+++ b/packages/backend/src/waitlist/controller/waitlist.controller.ts
@@ -1,10 +1,8 @@
 import { Request, Response } from "express";
 import { BaseError } from "@core/errors/errors.base";
 import { Logger } from "@core/logger/winston.logger";
-import {
-  AnswerMap,
-  Schema_Waitlist,
-} from "@core/types/waitlist/waitlist.types";
+import { AnswerMap } from "@core/types/waitlist/waitlist.answer.types";
+import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
 import { isMissingWaitlistTagId } from "@backend/common/constants/env.util";
 import WaitlistService from "../service/waitlist.service";
 
@@ -49,4 +47,50 @@ export class WaitlistController {
       return res.status(500).json({ error: "Server error" });
     }
   }
+
+  static async isInvited(
+    req: Request<unknown, unknown, unknown, { email: string }>,
+    res: Response<{ isInvited: boolean }>,
+  ) {
+    const email = req.query.email;
+    if (!email) {
+      logger.error("Could not check if invited due to missing request email");
+      return res.status(400).json({
+        isInvited: false,
+      });
+    }
+
+    const isInvited = await WaitlistService.isInvited(email);
+    return res.status(200).json({ isInvited });
+  }
+
+  static async isOnWaitlist(
+    req: Request<unknown, unknown, unknown, { email: string }>,
+    res: Response<{ isOnWaitlist: boolean }>,
+  ) {
+    const email = req.query.email;
+    if (!email) {
+      logger.error("Could not check waitlist due to missing request email");
+      return res.status(400).json({ isOnWaitlist: false });
+    }
+
+    const isOnWaitlist = await WaitlistService.isOnWaitlist(email);
+    return res.status(200).json({ isOnWaitlist });
+  }
+  static async status(
+    req: Request<unknown, unknown, unknown, { email: string }>,
+    res: Response<{ isOnWaitlist: boolean; isInvited: boolean }>,
+  ) {
+    const email = req.query.email;
+    if (!email) {
+      logger.error("Could not check waitlist due to missing request email");
+      return res.status(400).json({ isOnWaitlist: false, isInvited: false });
+    }
+
+    const [isOnWaitlist, isInvited] = await Promise.all([
+      WaitlistService.isOnWaitlist(email),
+      WaitlistService.isInvited(email),
+    ]);
+    return res.status(200).json({ isOnWaitlist, isInvited });
+  }
 }
diff --git a/packages/backend/src/waitlist/repo/waitlist.repo.ts b/packages/backend/src/waitlist/repo/waitlist.repo.ts
index 8d58eb87..40dac84d 100644
--- a/packages/backend/src/waitlist/repo/waitlist.repo.ts
+++ b/packages/backend/src/waitlist/repo/waitlist.repo.ts
@@ -1,4 +1,9 @@
-import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
+import {
+  Result_InviteToWaitlist,
+  Schema_Waitlist,
+} from "@core/types/waitlist/waitlist.types";
+import { error } from "@backend/common/errors/handlers/error.handler";
+import { WaitlistError } from "@backend/common/errors/waitlist/waitlist.errors";
 import mongoService from "@backend/common/services/mongo.service";
 
 export class WaitlistRepository {
@@ -6,8 +11,58 @@ export class WaitlistRepository {
     return mongoService.waitlist.insertOne(record);
   }
 
+  static async invite(email: string): Promise<Result_InviteToWaitlist> {
+    const isOnWaitlist = await this.isAlreadyOnWaitlist(email);
+    if (!isOnWaitlist) {
+      throw error(WaitlistError.NotOnWaitlist, "Email is not on waitlist");
+    }
+
+    const result = await mongoService.waitlist.updateOne(
+      { email: { $eq: email } },
+      { $set: { status: "invited" } },
+    );
+
+    const invited = result.modifiedCount === 1;
+    return {
+      status: invited ? "invited" : "ignored",
+    };
+  }
+
   static async isAlreadyOnWaitlist(email: string) {
-    const match = await mongoService.waitlist.find({ email }).toArray();
+    const match = await mongoService.waitlist
+      .find({ email: { $eq: email } })
+      .toArray();
     return match.length > 0;
   }
+
+  static async isInvited(email: string) {
+    const record = await this._getWaitlistRecord(email);
+
+    if (!record) {
+      return false;
+    }
+
+    return record.status === "invited";
+  }
+
+  private static async _getWaitlistRecord(
+    email: string,
+  ): Promise<Schema_Waitlist | null> {
+    // Fetch up to 2 records to efficiently check for duplicates.
+    const matches = await mongoService.waitlist
+      .find({ email: { $eq: email } })
+      .limit(2)
+      .toArray();
+
+    if (matches.length > 1) {
+      throw error(WaitlistError.DuplicateEmail, "Unique email not returned");
+    }
+
+    if (matches.length === 0) {
+      return null; // No waitlist entry found for this email
+    }
+
+    // Exactly one match found
+    return matches[0] as Schema_Waitlist;
+  }
 }
diff --git a/packages/backend/src/waitlist/service/waitlist.service.ts b/packages/backend/src/waitlist/service/waitlist.service.ts
index 44785170..3c55191f 100644
--- a/packages/backend/src/waitlist/service/waitlist.service.ts
+++ b/packages/backend/src/waitlist/service/waitlist.service.ts
@@ -1,8 +1,9 @@
 import { Logger } from "@core/logger/winston.logger";
 import { Subscriber } from "@core/types/email/email.types";
+import { Answers } from "@core/types/waitlist/waitlist.answer.types";
 import {
+  Result_InviteToWaitlist,
   Result_Waitlist,
-  Schema_Answers,
 } from "@core/types/waitlist/waitlist.types";
 import { ENV } from "@backend/common/constants/env.constants";
 import EmailService from "../../email/email.service";
@@ -12,7 +13,7 @@ const logger = Logger("app:waitlist.service");
 class WaitlistService {
   static async addToWaitlist(
     email: string,
-    answer: Schema_Answers,
+    answer: Answers,
   ): Promise<Result_Waitlist> {
     if (ENV.EMAILER_SECRET && ENV.EMAILER_WAITLIST_TAG_ID) {
       const subscriber: Subscriber = {
@@ -45,12 +46,33 @@ class WaitlistService {
     await WaitlistRepository.addToWaitlist({
       ...answer,
       waitlistedAt: new Date().toISOString(),
+      status: "waitlisted",
     });
 
     return {
       status: "waitlisted",
     };
   }
+
+  static async invite(email: string): Promise<Result_InviteToWaitlist> {
+    try {
+      const result = await WaitlistRepository.invite(email);
+      return result;
+    } catch (error) {
+      logger.error("Failed to invite email to waitlist", error);
+      return {
+        status: "ignored",
+      };
+    }
+  }
+
+  static async isInvited(email: string): Promise<boolean> {
+    return WaitlistRepository.isInvited(email);
+  }
+
+  static async isOnWaitlist(email: string): Promise<boolean> {
+    return WaitlistRepository.isAlreadyOnWaitlist(email);
+  }
 }
 
 export default WaitlistService;
diff --git a/packages/backend/src/waitlist/waitlist.routes.config.ts b/packages/backend/src/waitlist/waitlist.routes.config.ts
index fcb60232..6c2d1fe2 100644
--- a/packages/backend/src/waitlist/waitlist.routes.config.ts
+++ b/packages/backend/src/waitlist/waitlist.routes.config.ts
@@ -8,7 +8,10 @@ export class WaitlistRoutes extends CommonRoutesConfig {
   }
 
   configureRoutes() {
-    this.app.post("/api/waitlist/v0", WaitlistController.addToWaitlist);
+    this.app
+      .route("/api/waitlist")
+      .post(WaitlistController.addToWaitlist)
+      .get(WaitlistController.status);
     return this.app;
   }
 }
diff --git a/packages/core/src/types/waitlist/waitlist.answer.types.ts b/packages/core/src/types/waitlist/waitlist.answer.types.ts
new file mode 100644
index 00000000..8759290e
--- /dev/null
+++ b/packages/core/src/types/waitlist/waitlist.answer.types.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+
+/* v0 */
+export const Schema_Answers_v0 = z.object({
+  email: z.string().email(),
+  schemaVersion: z.literal("0"),
+  source: z.enum(["search-engine", "social-media", "friend", "other"]),
+  firstName: z.string().min(2),
+  lastName: z.string().min(2),
+  currentlyPayingFor: z.array(z.string()).optional(),
+  howClearAboutValues: z.enum(["not-clear", "somewhat-clear", "very-clear"]),
+  workingTowardsMainGoal: z.enum([
+    "yes",
+    "no-but-want-to",
+    "no-and-dont-want-to",
+  ]),
+  isWillingToShare: z.boolean(),
+  anythingElse: z.string().optional(),
+});
+export type Answers_v0 = z.infer<typeof Schema_Answers_v0>;
+
+export type Answers = Answers_v0; // make a union type when adding more versions
+
+export const AnswerMap = {
+  v0: Schema_Answers_v0,
+  //v1: Schema_Answers_v1 <-- Extend/change for new version to avoid migration
+};
diff --git a/packages/core/src/types/waitlist/waitlist.types.ts b/packages/core/src/types/waitlist/waitlist.types.ts
index e8f49829..99fed1e3 100644
--- a/packages/core/src/types/waitlist/waitlist.types.ts
+++ b/packages/core/src/types/waitlist/waitlist.types.ts
@@ -1,38 +1,19 @@
 import { z } from "zod";
+import { Schema_Answers_v0 } from "./waitlist.answer.types";
 
 export interface Result_Waitlist {
   status: "waitlisted" | "ignored";
 }
 
-/* v0 */
-export const Schema_Answers_v0 = z.object({
-  email: z.string().email(),
-  schemaVersion: z.literal("0"),
-  source: z.enum(["search-engine", "social-media", "friend", "other"]),
-  firstName: z.string().min(2),
-  lastName: z.string().min(2),
-  currentlyPayingFor: z.array(z.string()).optional(),
-  howClearAboutValues: z.enum(["not-clear", "somewhat-clear", "very-clear"]),
-  workingTowardsMainGoal: z.enum([
-    "yes",
-    "no-but-want-to",
-    "no-and-dont-want-to",
-  ]),
-  isWillingToShare: z.boolean(),
-  anythingElse: z.string().optional(),
-});
-export type Answers_v0 = z.infer<typeof Schema_Answers_v0>;
+export interface Result_InviteToWaitlist {
+  status: "invited" | "ignored";
+}
+
+const Schema_Status = z.enum(["waitlisted", "invited", "active"]);
 
 const Schema_Waitlist_v0 = Schema_Answers_v0.extend({
+  status: Schema_Status,
   waitlistedAt: z.string().datetime(),
 });
 export type Schema_Waitlist_v0 = z.infer<typeof Schema_Waitlist_v0>;
-
-/* All */
 export type Schema_Waitlist = Schema_Waitlist_v0; // make a union type when adding more versions
-export type Schema_Answers = Answers_v0; // make a union type when adding more versions
-
-export const AnswerMap = {
-  v0: Schema_Answers_v0,
-  //v1: Schema_Answers_v1 <-- Extend/change for new version to avoid migration
-};
diff --git a/packages/web/src/common/apis/waitlist.api.ts b/packages/web/src/common/apis/waitlist.api.ts
new file mode 100644
index 00000000..c49a3543
--- /dev/null
+++ b/packages/web/src/common/apis/waitlist.api.ts
@@ -0,0 +1,11 @@
+import { CompassApi } from "./compass.api";
+
+export const WaitlistApi = {
+  async getWaitlistStatus(email: string) {
+    const { data } = await CompassApi.get<{
+      isOnWaitlist: boolean;
+      isInvited: boolean;
+    }>(`/waitlist`, { params: { email } });
+    return data;
+  },
+};
diff --git a/packages/web/src/views/Login/Login.tsx b/packages/web/src/views/Login/Login.tsx
index 5980b1c8..7c972e0b 100644
--- a/packages/web/src/views/Login/Login.tsx
+++ b/packages/web/src/views/Login/Login.tsx
@@ -5,27 +5,62 @@ import { v4 as uuidv4 } from "uuid";
 import { useGoogleLogin } from "@react-oauth/google";
 import { useAuthCheck } from "@web/auth/useAuthCheck";
 import { AuthApi } from "@web/common/apis/auth.api";
+import { WaitlistApi } from "@web/common/apis/waitlist.api";
 import { ROOT_ROUTES } from "@web/common/constants/routes";
 import { AlignItems, FlexDirections } from "@web/components/Flex/styled";
 import { LoginAbsoluteOverflowLoader } from "@web/components/LoginAbsoluteOverflowLoader/LoginAbsoluteOverflowLoader";
 import {
+  ActionButton,
   Card,
   CardHeader,
-  Description,
+  EmailFormContainer,
+  EmailInputField,
+  InfoText,
+  NavLinkContainer,
+  NavLinkIcon,
+  NavLinkText,
   SignInButtonWrapper,
   StyledLogin,
   StyledLoginContainer,
+  StyledNavLink,
   Subtitle,
+  TertiaryButton,
   Title,
 } from "./styled";
 
+type FlowStep = "initial" | "checkingWaitlist" | "waitlistStatusKnown";
+
 export const LoginView = () => {
+  const emailInputRef = useRef<HTMLInputElement>(null);
   const navigate = useNavigate();
-
   const [isAuthenticated, setIsAuthenticated] = useState(false);
-  const [isAuthenticating, setIsAuthenticating] = useState(false);
+  const [isAuthenticating, setIsAuthenticating] = useState(false); // For Google Login process
+
+  // New state for the waitlist check flow
+  const [emailInput, setEmailInput] = useState("");
+  const [flowStep, setFlowStep] = useState<FlowStep>("initial");
+  const [waitlistCheckResult, setWaitlistCheckResult] = useState<{
+    isOnWaitlist: boolean;
+    isInvited: boolean;
+  } | null>(null);
+  const [isLoadingWaitlistStatus, setIsLoadingWaitlistStatus] = useState(false);
+  const [apiError, setApiError] = useState<string | null>(null);
 
   const { isAuthenticated: isAlreadyAuthenticated } = useAuthCheck();
+  const antiCsrfToken = useRef(uuidv4()).current;
+
+  useEffect(() => {
+    if (window.location.hostname === "localhost") {
+      setWaitlistCheckResult({ isOnWaitlist: true, isInvited: true });
+      setFlowStep("waitlistStatusKnown");
+    }
+  }, []);
+
+  useEffect(() => {
+    if (flowStep === "initial" && emailInputRef.current) {
+      emailInputRef.current.focus();
+    }
+  }, [flowStep]);
 
   useEffect(() => {
     if (isAuthenticated) {
@@ -33,8 +68,6 @@ export const LoginView = () => {
     }
   }, [isAuthenticated, navigate]);
 
-  const antiCsrfToken = useRef(uuidv4()).current;
-
   const SCOPES_REQUIRED = [
     "email",
     "https://www.googleapis.com/auth/calendar.readonly",
@@ -43,26 +76,15 @@ export const LoginView = () => {
 
   const isMissingPermissions = (scope: string) => {
     const scopesGranted = scope.split(" ");
-
     return SCOPES_REQUIRED.some((s) => !scopesGranted.includes(s));
   };
 
-  const handleButtonClick = () => {
-    if (isAlreadyAuthenticated) {
-      navigate(ROOT_ROUTES.ROOT);
-    } else {
-      login();
-    }
-  };
-
-  const login = useGoogleLogin({
+  const startLoginFlow = useGoogleLogin({
     flow: "auth-code",
     scope: SCOPES_REQUIRED.join(" "),
     state: antiCsrfToken,
-
     onSuccess: async ({ code, scope, state }) => {
       const isFromHacker = state !== antiCsrfToken;
-
       if (isFromHacker) {
         alert("Nice try, hacker");
         return;
@@ -74,11 +96,13 @@ export const LoginView = () => {
       }
 
       setIsAuthenticating(true);
-
       try {
         await AuthApi.loginOrSignup(code);
         setIsAuthenticated(true);
       } catch (e) {
+        console.error(e);
+        alert("Login failed. Please try again.");
+      } finally {
         setIsAuthenticating(false);
       }
     },
@@ -88,6 +112,93 @@ export const LoginView = () => {
     },
   });
 
+  const checkWaitlistAndLogin = useGoogleLogin({
+    scope: "email",
+    state: antiCsrfToken,
+    onSuccess: async ({ access_token, state }) => {
+      const isFromHacker = state !== antiCsrfToken;
+      if (isFromHacker) {
+        alert("Nice try, hacker");
+        return;
+      }
+      try {
+        const res = await fetch(
+          "https://www.googleapis.com/oauth2/v3/userinfo",
+          {
+            headers: { Authorization: `Bearer ${access_token}` },
+          },
+        );
+        const profile = (await res.json()) as { email?: string };
+        const email = profile.email;
+
+        if (!email) {
+          alert("Could not retrieve email from Google");
+          return;
+        }
+
+        const { isInvited, isOnWaitlist } =
+          await WaitlistApi.getWaitlistStatus(email);
+
+        if (isInvited) {
+          startLoginFlow();
+          return;
+        }
+
+        setWaitlistCheckResult({ isOnWaitlist, isInvited });
+        setFlowStep("waitlistStatusKnown");
+      } catch (e) {
+        console.error(e);
+        alert("Failed to check waitlist status");
+      }
+    },
+    onError: (error) => {
+      alert(`Login failed because: ${error.error}`);
+      console.error(error);
+    },
+  });
+
+  const handleCheckWaitlistSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    const processedInput = emailInput.trim().toLowerCase();
+
+    if (processedInput === "marco@polo.co") {
+      setApiError(null); // Clear any previous error
+      setWaitlistCheckResult({ isOnWaitlist: true, isInvited: true });
+      setFlowStep("waitlistStatusKnown");
+      return;
+    }
+
+    if (!emailInput.trim()) {
+      setApiError("Please enter your email address.");
+      return;
+    }
+    setIsLoadingWaitlistStatus(true);
+    setApiError(null);
+    setFlowStep("checkingWaitlist");
+    try {
+      const data = await WaitlistApi.getWaitlistStatus(emailInput.trim());
+      setWaitlistCheckResult(data);
+      setFlowStep("waitlistStatusKnown");
+    } catch (error) {
+      console.error("Error checking waitlist status:", error);
+      setApiError("Failed to check waitlist status. Please try again.");
+      setFlowStep("initial"); // Revert to initial to allow retry
+    } finally {
+      setIsLoadingWaitlistStatus(false);
+    }
+  };
+
+  const handleButtonClick = () => {
+    // This is for the Google Sign-In button
+    if (isAlreadyAuthenticated) {
+      navigate(ROOT_ROUTES.ROOT);
+      return;
+    }
+    // If this button is visible, it means the user is invited.
+    // Proceed directly to the main login flow.
+    startLoginFlow();
+  };
+
   return (
     <>
       <StyledLoginContainer>
@@ -99,20 +210,129 @@ export const LoginView = () => {
 
           <Card>
             <CardHeader>
-              <Title>Welcome to Compass</Title>
-              <Subtitle>The weekly planner for minimalists</Subtitle>
+              <Title>Compass</Title>
+              <Subtitle>The weekly planner for ambitious minimalists</Subtitle>
             </CardHeader>
-            <Description>You're almost ready to start planning!</Description>
-            <Description>
-              Now let's import your events from Google Calendar
-            </Description>
-            <SignInButtonWrapper>
-              <GoogleButton
-                aria-label="Sign in with Google"
-                type="light"
-                onClick={handleButtonClick}
-              />
-            </SignInButtonWrapper>
+
+            {flowStep === "initial" && (
+              <>
+                <InfoText>
+                  Compass is currently invite-only. Please enter your email to
+                  check your waitlist status.
+                </InfoText>
+                <EmailFormContainer onSubmit={handleCheckWaitlistSubmit}>
+                  <EmailInputField
+                    type="email"
+                    placeholder="Enter your email"
+                    value={emailInput}
+                    onChange={(e) => setEmailInput(e.target.value)}
+                    disabled={isLoadingWaitlistStatus}
+                    ref={emailInputRef}
+                  />
+                  <ActionButton
+                    type="submit"
+                    disabled={
+                      isLoadingWaitlistStatus || emailInput.trim() === ""
+                    }
+                  >
+                    {isLoadingWaitlistStatus
+                      ? "Checking..."
+                      : "Check Waitlist Status"}
+                  </ActionButton>
+                </EmailFormContainer>
+                {apiError && (
+                  <InfoText style={{ color: "red" }}>{apiError}</InfoText>
+                )}
+              </>
+            )}
+
+            {flowStep === "checkingWaitlist" && (
+              <InfoText>Checking your status on the waitlist...</InfoText>
+            )}
+
+            {flowStep === "waitlistStatusKnown" && waitlistCheckResult && (
+              <>
+                {!waitlistCheckResult.isOnWaitlist && (
+                  <>
+                    <InfoText>
+                      You are not on the waitlist yet. Sign up to get notified
+                      when a spot opens up!
+                    </InfoText>
+                    <TertiaryButton
+                      href="https://www.compasscalendar.com/waitlist"
+                      target="_blank"
+                      rel="noreferrer"
+                    >
+                      Sign Up for Waitlist
+                    </TertiaryButton>
+                  </>
+                )}
+
+                {waitlistCheckResult.isOnWaitlist &&
+                  !waitlistCheckResult.isInvited && (
+                    <>
+                      <InfoText>
+                        You're on the waitlist! We're carefully reviewing
+                        applicants and will notify you once you're invited. In
+                        the meantime, you can engage with us in these ways:
+                      </InfoText>
+                      <NavLinkContainer>
+                        <StyledNavLink
+                          href="https://github.com/SwitchbackTech/compass"
+                          target="_blank"
+                          rel="noreferrer"
+                        >
+                          <NavLinkIcon>👨‍💻</NavLinkIcon>
+                          <NavLinkText>
+                            View the code (we're open source!)
+                          </NavLinkText>
+                        </StyledNavLink>
+                        <StyledNavLink
+                          href="https://youtube.com/playlist?list=PLPQAVocXPdjmYaPM9MXzplcwgoXZ_yPiJ&si=ypf5Jg8tZt6Tez36"
+                          target="_blank"
+                          rel="noreferrer"
+                        >
+                          <NavLinkIcon>📺</NavLinkIcon>
+                          <NavLinkText>Watch Compass on YouTube</NavLinkText>
+                        </StyledNavLink>
+                        <StyledNavLink
+                          href="https://buymeacoffee.com/tylerdane"
+                          target="_blank"
+                          rel="noreferrer"
+                        >
+                          <NavLinkIcon>☕</NavLinkIcon>
+                          <NavLinkText>Support with a donation</NavLinkText>
+                        </StyledNavLink>
+                      </NavLinkContainer>
+                    </>
+                  )}
+
+                {waitlistCheckResult.isOnWaitlist &&
+                  waitlistCheckResult.isInvited && (
+                    <>
+                      <InfoText>
+                        Great news! You're invited to join Compass. Sign in with
+                        Google to get started.
+                      </InfoText>
+                      <SignInButtonWrapper>
+                        <GoogleButton
+                          aria-label="Sign in with Google"
+                          type="light"
+                          onClick={handleButtonClick}
+                          disabled={isAuthenticating}
+                        />
+                      </SignInButtonWrapper>
+                      {isAuthenticating && (
+                        <InfoText>Connecting to Google...</InfoText>
+                      )}
+                    </>
+                  )}
+              </>
+            )}
+            {/* Render API error if it occurred during the Google login phase, distinct from waitlist check error */}
+            {isAuthenticating && apiError && (
+              <InfoText style={{ color: "red" }}>{apiError}</InfoText>
+            )}
           </Card>
         </StyledLogin>
       </StyledLoginContainer>
diff --git a/packages/web/src/views/Login/styled.ts b/packages/web/src/views/Login/styled.ts
index cbca723a..576d8954 100644
--- a/packages/web/src/views/Login/styled.ts
+++ b/packages/web/src/views/Login/styled.ts
@@ -1,4 +1,5 @@
 import styled, { keyframes } from "styled-components";
+import { Btn } from "@web/components/Button/styled";
 import { Flex } from "@web/components/Flex";
 import { darkBlueGradient } from "../../common/styles/theme.util";
 
@@ -86,3 +87,182 @@ export const SignInButtonWrapper = styled.div`
   width: 100%;
   padding: 2.625rem 1rem;
 `;
+
+export const WaitlistBtn = styled(Btn)`
+  background: ${({ theme }) => theme.color.status.info};
+  color: ${({ theme }) => theme.color.text.dark};
+  padding: 0.5rem 1rem;
+  margin-top: 1rem;
+  &:hover {
+    filter: brightness(120%);
+  }
+`;
+
+export const NavLinkContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  align-items: center;
+  margin: 1.5rem 0;
+  padding: 1.25rem;
+  background: rgba(255, 255, 255, 0.03);
+  border-radius: 12px;
+  border: 1px solid rgba(255, 255, 255, 0.05);
+  transition: all 0.3s ease;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.05);
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  }
+`;
+
+export const StyledNavLink = styled.a`
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+  color: ${({ theme }) => theme.color.status.info};
+  text-decoration: none;
+  font-weight: ${({ theme }) => theme.text.weight.medium};
+  padding: 0.5rem 0.75rem;
+  border-radius: 8px;
+  transition: all 0.2s ease;
+  width: 100%;
+
+  &:hover,
+  &:focus {
+    background: rgba(255, 255, 255, 0.05);
+    transform: translateY(-2px);
+    text-decoration: none;
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+`;
+
+export const NavLinkIcon = styled.span`
+  font-size: 1.2rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+export const NavLinkText = styled.span`
+  font-size: 1rem;
+`;
+
+export const InfoText = styled.p`
+  color: ${({ theme }) =>
+    theme.color.text.lightInactive}; /* Mapped from c.gray200 */
+  font-size: 1rem;
+  text-align: center;
+  margin-top: 0.5rem;
+  margin-bottom: 1.5rem;
+  line-height: 1.5;
+`;
+
+export const EmailFormContainer = styled.form`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 1rem;
+  width: 100%;
+  max-width: 400px;
+  margin-left: auto;
+  margin-right: auto;
+  margin-bottom: 1.5rem;
+`;
+
+export const EmailInputField = styled.input`
+  width: 100%;
+  padding: 0.8rem 1rem;
+  border-radius: ${({ theme }) => theme.shape.borderRadius};
+  border: 1px solid ${({ theme }) => theme.color.border.primary}; /* Was gray700, using border.primary (c.gray800) */
+  background-color: ${({ theme }) =>
+    theme.color.bg
+      .secondary}; /* Was darkBlue300, using bg.secondary (c.darkBlue200) */
+  color: ${({ theme }) =>
+    theme.color.text.lighter}; /* Was white100, mapped from c.white100 */
+  font-size: 1rem;
+  box-sizing: border-box;
+
+  &::placeholder {
+    color: ${({ theme }) =>
+      theme.color.text
+        .darkPlaceholder}; /* Was gray400, mapped from c.gray300 */
+  }
+
+  &:focus {
+    outline: none;
+    border-color: ${({ theme }) =>
+      theme.color.text.accent}; /* Was blue200, using text.accent (c.blue100) */
+    box-shadow: 0 0 0 3px ${({ theme }) => `${theme.color.text.accent}4D`}; /* Was blue100 for shadow base, using text.accent (c.blue100) */
+  }
+`;
+
+export const ActionButton = styled.button`
+  background-color: ${({ theme }) =>
+    theme.color.status.info}; /* Was blue200, using status.info (c.blue100) */
+  color: ${({ theme }) =>
+    theme.color.text
+      .dark}; /* Was white100, using general dark text for contrast on blue100 */
+  padding: 0.8rem 1.5rem;
+  border: none;
+  border-radius: ${({ theme }) => theme.shape.borderRadius};
+  font-size: 1rem;
+  font-weight: ${({ theme }) => theme.text.weight.medium};
+  cursor: pointer;
+  transition:
+    background-color 0.2s ease,
+    transform 0.1s ease;
+  width: 100%;
+  box-sizing: border-box;
+
+  &:hover {
+    background-color: ${({ theme }) =>
+      theme.color.text
+        .accent}; /* Was blue300, using text.accent (c.blue100) for hover, assuming it's visually distinct or can be adjusted later */
+  }
+
+  &:active {
+    transform: translateY(1px);
+  }
+
+  &:disabled {
+    background-color: ${({ theme }) =>
+      theme.color.border
+        .primaryDark}; /* Was gray500, using border.primaryDark (c.gray900) */
+    color: ${({ theme }) =>
+      theme.color.text
+        .darkPlaceholder}; /* Was gray300, mapped from c.gray300 */
+    cursor: not-allowed;
+  }
+`;
+
+export const TertiaryButton = styled.a`
+  display: inline-block;
+  background-color: transparent;
+  color: ${({ theme }) =>
+    theme.color.text.accent}; /* Was blue200, using text.accent (c.blue100) */
+  padding: 0.8rem 1.5rem;
+  border: 1px solid ${({ theme }) => theme.color.text.accent}; /* Was blue200, using text.accent (c.blue100) */
+  border-radius: ${({ theme }) => theme.shape.borderRadius};
+  font-size: 1rem;
+  font-weight: ${({ theme }) => theme.text.weight.medium};
+  cursor: pointer;
+  text-decoration: none;
+  text-align: center;
+  transition:
+    background-color 0.2s ease,
+    color 0.2s ease;
+  width: 100%;
+  max-width: 400px;
+  box-sizing: border-box;
+
+  &:hover {
+    background-color: ${({ theme }) =>
+      `${theme.color.text.accent}20`}; /* Was blue200 for base, using text.accent (c.blue100) */
+    color: ${({ theme }) =>
+      theme.color.text.accent}; /* Was blue100, using text.accent (c.blue100) */
+  }
+`;


Test patch:
diff --git a/packages/backend/src/__tests__/helpers/mock.db.setup.ts b/packages/backend/src/__tests__/helpers/mock.db.setup.ts
index 90d2b764..c732b7ad 100644
--- a/packages/backend/src/__tests__/helpers/mock.db.setup.ts
+++ b/packages/backend/src/__tests__/helpers/mock.db.setup.ts
@@ -93,7 +93,6 @@ export async function setupTestDb(): Promise<TestSetup> {
   // Create waitlist user
   await mongoService.waitlist.insertOne({
     email,
-    waitlistedAt: new Date().toISOString(),
     schemaVersion: "0",
     source: "other",
     firstName: "Test",
@@ -102,6 +101,8 @@ export async function setupTestDb(): Promise<TestSetup> {
     howClearAboutValues: "not-clear",
     workingTowardsMainGoal: "yes",
     isWillingToShare: false,
+    status: "waitlisted",
+    waitlistedAt: new Date().toISOString(),
   });
 
   return { mongoServer, mongoClient, db, userId: userId.toString(), email };
diff --git a/packages/backend/src/waitlist/controller/waitlist.controller.test.ts b/packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts
similarity index 91%
rename from packages/backend/src/waitlist/controller/waitlist.controller.test.ts
rename to packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts
index e44818d4..229b990d 100644
--- a/packages/backend/src/waitlist/controller/waitlist.controller.test.ts
+++ b/packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts
@@ -1,5 +1,5 @@
 import request from "supertest";
-import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
+import type { Answers } from "@core/types/waitlist/waitlist.answer.types";
 
 describe("POST /api/waitlist", () => {
   beforeEach(() => {
@@ -41,7 +41,7 @@ describe("POST /api/waitlist", () => {
     app.use(express.json());
     app.post("/api/waitlist", WaitlistController.addToWaitlist);
 
-    const answers: Schema_Waitlist = {
+    const answers: Answers = {
       source: "social-media",
       firstName: "Jo",
       lastName: "Schmo",
@@ -50,7 +50,6 @@ describe("POST /api/waitlist", () => {
       howClearAboutValues: "not-clear",
       workingTowardsMainGoal: "yes",
       isWillingToShare: false,
-      waitlistedAt: new Date().toISOString(),
       schemaVersion: "0",
     };
     const res = await request(app).post("/api/waitlist").send(answers);
@@ -70,7 +69,7 @@ describe("POST /api/waitlist", () => {
     app.use(express.json());
     app.post("/api/waitlist", WaitlistController.addToWaitlist);
 
-    const answers: Schema_Waitlist = {
+    const answers: Answers = {
       source: "other",
       firstName: "Jo",
       lastName: "Schmo",
@@ -79,7 +78,6 @@ describe("POST /api/waitlist", () => {
       howClearAboutValues: "not-clear",
       workingTowardsMainGoal: "yes",
       isWillingToShare: false,
-      waitlistedAt: new Date().toISOString(),
       schemaVersion: "0",
     };
     const res = await request(app).post("/api/waitlist").send(answers);
diff --git a/packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts b/packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts
new file mode 100644
index 00000000..5ade607b
--- /dev/null
+++ b/packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts
@@ -0,0 +1,86 @@
+import request from "supertest";
+
+describe("GET /api/waitlist", () => {
+  beforeEach(() => {
+    jest.resetModules();
+    jest.clearAllMocks();
+  });
+
+  it("should return 400 if email is invalid", async () => {
+    // Arrange
+    jest.doMock("../service/waitlist.service", () => ({
+      __esModule: true,
+      default: {
+        isInvited: jest.fn(),
+        isOnWaitlist: jest.fn(),
+      },
+    }));
+    const { WaitlistController } = await import("./waitlist.controller");
+    const express = (await import("express")).default;
+    const app = express();
+    app.use(express.json());
+    app.get("/api/waitlist", WaitlistController.status);
+
+    // Act
+    const res = await request(app).get("/api/waitlist").query({ email: "" });
+
+    // Assert
+    expect(res.status).toBe(400);
+    expect(res.error).toBeDefined();
+  });
+
+  it("should return true if email was invited", async () => {
+    // Arrange
+    jest.doMock("../service/waitlist.service", () => ({
+      __esModule: true,
+      default: {
+        isInvited: jest.fn().mockResolvedValue(true), // user is invited
+        isOnWaitlist: jest.fn().mockResolvedValue(true), // user is waitlisted
+      },
+    }));
+    const { WaitlistController } = await import("./waitlist.controller");
+    const express = (await import("express")).default;
+    const app = express();
+    app.use(express.json());
+    app.get("/api/waitlist", WaitlistController.status);
+
+    // Act
+    const res = await request(app)
+      .get("/api/waitlist")
+      .query({ email: "was-invited@bar.com" });
+
+    // Assert
+    expect(res.status).toBe(200);
+    const data = res.body;
+    expect(data.isInvited).toBeDefined();
+    expect(data.isInvited).toBe(true);
+  });
+
+  it("should return false if email was not invited", async () => {
+    // Arrange
+    jest.doMock("../service/waitlist.service", () => ({
+      __esModule: true,
+      default: {
+        isInvited: jest.fn().mockResolvedValue(false), // user is not invited
+        isOnWaitlist: jest.fn().mockResolvedValue(false), // user is not waitlisted
+      },
+    }));
+    const { WaitlistController } = await import("./waitlist.controller");
+    const express = (await import("express")).default;
+    const app = express();
+    app.use(express.json());
+    app.get("/api/waitlist", WaitlistController.status);
+
+    // Act
+    const res = await request(app)
+      .get("/api/waitlist")
+      .query({ email: "not-invited@bar.com" });
+
+    // Assert
+    expect(res.status).toBe(200);
+    const data = res.body;
+    expect(data.isInvited).toBeDefined();
+    expect(data.isInvited).toBe(false);
+    expect(data.isOnWaitlist).toBe(false);
+  });
+});
diff --git a/packages/backend/src/waitlist/service/waitlist.service.test.ts b/packages/backend/src/waitlist/service/waitlist.service-add.test.ts
similarity index 66%
rename from packages/backend/src/waitlist/service/waitlist.service.test.ts
rename to packages/backend/src/waitlist/service/waitlist.service-add.test.ts
index 881a2b96..6498a0fc 100644
--- a/packages/backend/src/waitlist/service/waitlist.service.test.ts
+++ b/packages/backend/src/waitlist/service/waitlist.service-add.test.ts
@@ -1,7 +1,4 @@
-import {
-  Result_Waitlist,
-  Schema_Waitlist,
-} from "@core/types/waitlist/waitlist.types";
+import { Result_Waitlist } from "@core/types/waitlist/waitlist.types";
 import {
   getEmailsOnWaitlist,
   isEmailOnWaitlist,
@@ -11,52 +8,8 @@ import {
   cleanupTestMongo,
   setupTestDb,
 } from "@backend/__tests__/helpers/mock.db.setup";
-import EmailService from "../../email/email.service";
 import WaitlistService from "./waitlist.service";
-
-const answer: Schema_Waitlist = {
-  firstName: "Jo",
-  lastName: "Schmo",
-  source: "other",
-  email: "joe@schmo.com",
-  currentlyPayingFor: undefined,
-  howClearAboutValues: "not-clear",
-  workingTowardsMainGoal: "yes",
-  isWillingToShare: false,
-  waitlistedAt: new Date().toISOString(),
-  schemaVersion: "0",
-};
-
-// Mock emailer API calls
-jest.spyOn(EmailService, "upsertSubscriber").mockResolvedValue({
-  subscriber: {
-    id: 1,
-    first_name: answer.firstName,
-    email_address: answer.email,
-    state: "active",
-    created_at: new Date().toISOString(),
-    fields: {
-      "Last name": answer.lastName,
-      Birthday: "1970-01-01",
-      Source: answer.source,
-    },
-  },
-});
-jest.spyOn(EmailService, "addTagToSubscriber").mockResolvedValue({
-  subscriber: {
-    id: 1,
-    first_name: answer.firstName,
-    email_address: answer.email,
-    state: "active",
-    created_at: new Date().toISOString(),
-    tagged_at: new Date().toISOString(),
-    fields: {
-      "Last name": answer.lastName,
-      Birthday: "1970-01-01",
-      Source: answer.source,
-    },
-  },
-});
+import { answer } from "./waitlist.service.test-setup";
 
 describe("addToWaitlist", () => {
   let setup: Awaited<ReturnType<typeof setupTestDb>>;
diff --git a/packages/backend/src/waitlist/service/waitlist.service-check.test.ts b/packages/backend/src/waitlist/service/waitlist.service-check.test.ts
new file mode 100644
index 00000000..fbbd5531
--- /dev/null
+++ b/packages/backend/src/waitlist/service/waitlist.service-check.test.ts
@@ -0,0 +1,36 @@
+import {
+  cleanupCollections,
+  cleanupTestMongo,
+  setupTestDb,
+} from "@backend/__tests__/helpers/mock.db.setup";
+import WaitlistService from "./waitlist.service";
+import { answer } from "./waitlist.service.test-setup";
+
+describe("isOnWaitlist", () => {
+  let setup: Awaited<ReturnType<typeof setupTestDb>>;
+
+  beforeAll(async () => {
+    setup = await setupTestDb();
+  });
+
+  beforeEach(async () => {
+    await cleanupCollections(setup.db);
+  });
+
+  afterAll(async () => {
+    await cleanupTestMongo(setup);
+  });
+
+  it("should return false if email is not waitlisted", async () => {
+    const result = await WaitlistService.isOnWaitlist(answer.email);
+    expect(result).toBe(false);
+  });
+
+  it("should return true if email is waitlisted", async () => {
+    await WaitlistService.addToWaitlist(answer.email, answer);
+
+    const result = await WaitlistService.isOnWaitlist(answer.email);
+
+    expect(result).toBe(true);
+  });
+});
diff --git a/packages/backend/src/waitlist/service/waitlist.service-invite.test.ts b/packages/backend/src/waitlist/service/waitlist.service-invite.test.ts
new file mode 100644
index 00000000..157868b7
--- /dev/null
+++ b/packages/backend/src/waitlist/service/waitlist.service-invite.test.ts
@@ -0,0 +1,76 @@
+import {
+  cleanupCollections,
+  cleanupTestMongo,
+  setupTestDb,
+} from "@backend/__tests__/helpers/mock.db.setup";
+import WaitlistService from "./waitlist.service";
+import { answer } from "./waitlist.service.test-setup";
+
+describe("isInvited", () => {
+  let setup: Awaited<ReturnType<typeof setupTestDb>>;
+
+  beforeAll(async () => {
+    setup = await setupTestDb();
+  });
+
+  beforeEach(async () => {
+    await cleanupCollections(setup.db);
+  });
+
+  afterAll(async () => {
+    await cleanupTestMongo(setup);
+  });
+
+  it("should return false if email is not invited", async () => {
+    // simulates when user was waitlisted but not invited
+    const result = await WaitlistService.isInvited(answer.email);
+    expect(result).toBe(false);
+  });
+
+  it("should return true if email is invited", async () => {
+    // Arrange
+    await WaitlistService.addToWaitlist(answer.email, answer);
+    await WaitlistService.invite(answer.email);
+
+    // Act
+    const result = await WaitlistService.isInvited(answer.email);
+
+    // Assert
+    expect(result).toBe(true);
+  });
+});
+
+describe("invite", () => {
+  let setup: Awaited<ReturnType<typeof setupTestDb>>;
+
+  beforeAll(async () => {
+    setup = await setupTestDb();
+  });
+
+  beforeEach(async () => {
+    await cleanupCollections(setup.db);
+  });
+
+  afterAll(async () => {
+    await cleanupTestMongo(setup);
+  });
+
+  it("should invite email to waitlist", async () => {
+    // Arrange
+    await WaitlistService.addToWaitlist(answer.email, answer);
+
+    // Act
+    const result = await WaitlistService.invite(answer.email);
+
+    // Assert
+    expect(result.status).toBe("invited");
+  });
+
+  it("should ignore if email is not on waitlist", async () => {
+    // Act
+    const result = await WaitlistService.invite(answer.email);
+
+    // Assert
+    expect(result.status).toBe("ignored");
+  });
+});
diff --git a/packages/backend/src/waitlist/service/waitlist.service.test-setup.ts b/packages/backend/src/waitlist/service/waitlist.service.test-setup.ts
new file mode 100644
index 00000000..6001b207
--- /dev/null
+++ b/packages/backend/src/waitlist/service/waitlist.service.test-setup.ts
@@ -0,0 +1,49 @@
+import { Answers } from "@core/types/waitlist/waitlist.answer.types";
+import EmailService from "@backend/email/email.service";
+
+/**
+ * Create mocks for waitlist service tests
+ */
+
+export const answer: Answers = {
+  firstName: "Jo",
+  lastName: "Schmo",
+  source: "other",
+  email: "joe@schmo.com",
+  currentlyPayingFor: undefined,
+  howClearAboutValues: "not-clear",
+  workingTowardsMainGoal: "yes",
+  isWillingToShare: false,
+  schemaVersion: "0",
+};
+
+// Mock emailer API calls
+jest.spyOn(EmailService, "upsertSubscriber").mockResolvedValue({
+  subscriber: {
+    id: 1,
+    first_name: answer.firstName,
+    email_address: answer.email,
+    state: "active",
+    created_at: new Date().toISOString(),
+    fields: {
+      "Last name": answer.lastName,
+      Birthday: "1970-01-01",
+      Source: answer.source,
+    },
+  },
+});
+jest.spyOn(EmailService, "addTagToSubscriber").mockResolvedValue({
+  subscriber: {
+    id: 1,
+    first_name: answer.firstName,
+    email_address: answer.email,
+    state: "active",
+    created_at: new Date().toISOString(),
+    tagged_at: new Date().toISOString(),
+    fields: {
+      "Last name": answer.lastName,
+      Birthday: "1970-01-01",
+      Source: answer.source,
+    },
+  },
+});