From 27e0831b6d2300ea1de80931e4b7efe7dbd08b04 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 1 Mar 2026 19:41:28 -0800 Subject: [PATCH] refactor(waitlist): remove waitlist functionality and related tests - Deleted the waitlist driver, controller, and service files, along with their associated tests, to streamline the codebase. - Removed references to waitlist in the Jest configuration and environment variables. - Updated related files to eliminate dependencies on the waitlist functionality, enhancing overall project clarity and maintainability. --- jest.config.js | 1 - packages/backend/.env.local.example | 2 - .../src/__tests__/backend.test.init.ts | 2 - .../src/__tests__/drivers/util.driver.ts | 8 +- .../src/__tests__/drivers/waitlist.driver.ts | 31 --- .../src/__tests__/helpers/mock.db.queries.ts | 24 +- .../src/common/constants/collections.ts | 1 - .../src/common/constants/env.constants.ts | 4 - .../backend/src/common/constants/env.util.ts | 8 - .../common/errors/waitlist/waitlist.errors.ts | 20 -- .../src/common/services/mongo.service.ts | 36 +-- .../src/servers/express/express.server.ts | 6 +- .../waitlist.controller-add.test.ts | 143 ----------- .../waitlist.controller-check.test.ts | 214 ---------------- .../controller/waitlist.controller.ts | 109 -------- .../src/waitlist/repo/waitlist.repo.ts | 87 ------- .../service/waitlist.service-add.test.ts | 105 -------- .../service/waitlist.service-check.test.ts | 41 --- .../waitlist.service-getAllWaitlisted.test.ts | 38 --- .../service/waitlist.service-invite.test.ts | 131 ---------- .../src/waitlist/service/waitlist.service.ts | 102 -------- .../service/waitlist.service.util.test.ts | 237 ------------------ .../waitlist/service/waitlist.service.util.ts | 39 --- .../src/waitlist/types/waitlist.types.ts | 7 - .../src/waitlist/waitlist.routes.config.ts | 17 -- .../src/mappers/subscriber/map.subscriber.ts | 25 -- .../types/waitlist/waitlist.answer.types.ts | 26 -- .../core/src/types/waitlist/waitlist.types.ts | 26 -- packages/scripts/src/cli.test.ts | 12 - packages/scripts/src/cli.ts | 7 - packages/scripts/src/commands/invite.ts | 25 -- 31 files changed, 18 insertions(+), 1516 deletions(-) delete mode 100644 packages/backend/src/__tests__/drivers/waitlist.driver.ts delete mode 100644 packages/backend/src/common/errors/waitlist/waitlist.errors.ts delete mode 100644 packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts delete mode 100644 packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts delete mode 100644 packages/backend/src/waitlist/controller/waitlist.controller.ts delete mode 100644 packages/backend/src/waitlist/repo/waitlist.repo.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service-add.test.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service-check.test.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service-getAllWaitlisted.test.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service-invite.test.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service.util.test.ts delete mode 100644 packages/backend/src/waitlist/service/waitlist.service.util.ts delete mode 100644 packages/backend/src/waitlist/types/waitlist.types.ts delete mode 100644 packages/backend/src/waitlist/waitlist.routes.config.ts delete mode 100644 packages/core/src/types/waitlist/waitlist.answer.types.ts delete mode 100644 packages/core/src/types/waitlist/waitlist.types.ts delete mode 100644 packages/scripts/src/commands/invite.ts diff --git a/jest.config.js b/jest.config.js index 95f101bfd..fc60ffc05 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,6 @@ const backendProject = { "^@backend/servers(/(.*)$)?": "/packages/backend/src/servers/$1", "^@backend/sync(/(.*)$)?": "/packages/backend/src/sync/$1", "^@backend/user(/(.*)$)?": "/packages/backend/src/user/$1", - "^@backend/waitlist(/(.*)$)?": "/packages/backend/src/waitlist/$1", "^@backend/__tests__(/(.*)$)?": "/packages/backend/src/__tests__/$1", }, diff --git a/packages/backend/.env.local.example b/packages/backend/.env.local.example index 16e832cdc..8a1895dc6 100644 --- a/packages/backend/.env.local.example +++ b/packages/backend/.env.local.example @@ -72,8 +72,6 @@ SUPERTOKENS_KEY=UNIQUE_KEY_FROM_YOUR_SUPERTOKENS_ACCOUNT # integration during signup will be skipped # EMAILER_API_SECRET=UNIQUE_SECRET_FROM_YOUR_KIT_ACCOUNT -# EMAILER_WAITLIST_TAG_ID=YOUR_WAITLIST_TAG_ID -# EMAILER_WAITLIST_INVITE_TAG_ID=YOUR_WAITLIST_INVITE_TAG_ID # EMAILER_USER_TAG_ID=YOUR_USER_TAG_ID #################################################### diff --git a/packages/backend/src/__tests__/backend.test.init.ts b/packages/backend/src/__tests__/backend.test.init.ts index 1fdd5b2b7..647d9d26d 100644 --- a/packages/backend/src/__tests__/backend.test.init.ts +++ b/packages/backend/src/__tests__/backend.test.init.ts @@ -12,8 +12,6 @@ process.env["CHANNEL_EXPIRATION_MIN"] = "5"; process.env["SUPERTOKENS_URI"] = "http://localhost:3000"; process.env["SUPERTOKENS_KEY"] = "sTKey"; process.env["EMAILER_API_SECRET"] = "emailerApiSecret"; -process.env["EMAILER_WAITLIST_TAG_ID"] = "1234567"; -process.env["EMAILER_WAITLIST_INVITE_TAG_ID"] = "7654321"; process.env["EMAILER_USER_TAG_ID"] = "910111213"; process.env["TOKEN_GCAL_NOTIFICATION"] = "secretToken1"; process.env["TOKEN_COMPASS_SYNC"] = "secretToken2"; diff --git a/packages/backend/src/__tests__/drivers/util.driver.ts b/packages/backend/src/__tests__/drivers/util.driver.ts index 37f39d109..44c5aae9d 100644 --- a/packages/backend/src/__tests__/drivers/util.driver.ts +++ b/packages/backend/src/__tests__/drivers/util.driver.ts @@ -2,18 +2,12 @@ import { WithId } from "mongodb"; import { Schema_User } from "@core/types/user.types"; import { SyncDriver } from "@backend/__tests__/drivers/sync.driver"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; -import { WaitListDriver } from "@backend/__tests__/drivers/waitlist.driver"; export class UtilDriver { static async setupTestUser(): Promise<{ user: WithId }> { const user = await UserDriver.createUser(); - await Promise.all([ - SyncDriver.createSync(user, true), - WaitListDriver.saveWaitListRecord( - WaitListDriver.createWaitListRecord(user), - ), - ]); + await SyncDriver.createSync(user, true); return { user }; } diff --git a/packages/backend/src/__tests__/drivers/waitlist.driver.ts b/packages/backend/src/__tests__/drivers/waitlist.driver.ts deleted file mode 100644 index abbf3d7cc..000000000 --- a/packages/backend/src/__tests__/drivers/waitlist.driver.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { WithId } from "mongodb"; -import { Schema_User } from "@core/types/user.types"; -import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types"; -import mongoService from "@backend/common/services/mongo.service"; - -export class WaitListDriver { - static createWaitListRecord( - user: Pick, "email" | "firstName" | "lastName">, - ): Schema_Waitlist { - return { - email: user.email, - schemaVersion: "1", - source: "other", - firstName: user.firstName, - lastName: user.lastName, - profession: "Software Engineer", - currentlyPayingFor: ["superhuman", "notion"], - anythingElse: "I'm a test", - status: "waitlisted", - waitlistedAt: new Date().toISOString(), - }; - } - - static async saveWaitListRecord( - waitListRecord: Schema_Waitlist, - ): Promise> { - const created = await mongoService.waitlist.insertOne(waitListRecord); - - return { _id: created.insertedId, ...waitListRecord }; - } -} diff --git a/packages/backend/src/__tests__/helpers/mock.db.queries.ts b/packages/backend/src/__tests__/helpers/mock.db.queries.ts index a2365bea3..6e4cc9b07 100644 --- a/packages/backend/src/__tests__/helpers/mock.db.queries.ts +++ b/packages/backend/src/__tests__/helpers/mock.db.queries.ts @@ -1,10 +1,7 @@ -import { Filter } from "mongodb"; -import { Event_Core, Schema_Event } from "@core/types/event.types"; -import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types"; -import { Collections } from "@backend/common/constants/collections"; +import { type Filter } from "mongodb"; +import { type Event_Core, type Schema_Event } from "@core/types/event.types"; import mongoService from "@backend/common/services/mongo.service"; -import { Event_API } from "@backend/common/types/backend.event.types"; -import { getNormalizedEmail } from "@backend/waitlist/service/waitlist.service.util"; +import { type Event_API } from "@backend/common/types/backend.event.types"; export const getCategorizedEventsInDb = async ( filter?: Filter>, @@ -26,21 +23,6 @@ export const getEventsInDb = async ( .toArray()) as unknown as Event_API[]; }; -export const getEmailsOnWaitlist = async () => { - const waitlist = (await mongoService.db - .collection(Collections.WAITLIST) - .find() - .toArray()) as unknown as Schema_Waitlist[]; - - const emails = waitlist.map((w) => w.email); - return emails; -}; - -export const isEmailOnWaitlist = async (email: string) => { - const normalizedEmail = getNormalizedEmail(email); - return (await getEmailsOnWaitlist()).includes(normalizedEmail); -}; - export const isEventCollectionEmpty = async ( filter: Filter> = {}, ) => { diff --git a/packages/backend/src/common/constants/collections.ts b/packages/backend/src/common/constants/collections.ts index 6506031d7..77bf08015 100644 --- a/packages/backend/src/common/constants/collections.ts +++ b/packages/backend/src/common/constants/collections.ts @@ -7,6 +7,5 @@ export const Collections = { PRIORITY: IS_DEV ? "_dev.priority" : "priority", SYNC: IS_DEV ? "_dev.sync" : "sync", USER: IS_DEV ? "_dev.user" : "user", - WAITLIST: IS_DEV ? "_dev.waitlist" : "waitlist", WATCH: IS_DEV ? "_dev.watch" : "watch", }; diff --git a/packages/backend/src/common/constants/env.constants.ts b/packages/backend/src/common/constants/env.constants.ts index 968b0994a..a38047e24 100644 --- a/packages/backend/src/common/constants/env.constants.ts +++ b/packages/backend/src/common/constants/env.constants.ts @@ -21,8 +21,6 @@ const EnvSchema = z GOOGLE_CLIENT_SECRET: z.string().nonempty(), DB: z.string().nonempty(), EMAILER_SECRET: z.string().nonempty().optional(), - EMAILER_WAITLIST_TAG_ID: z.string().nonempty().optional(), - EMAILER_WAITLIST_INVITE_TAG_ID: z.string().nonempty().optional(), EMAILER_USER_TAG_ID: z.string().nonempty().optional(), MONGO_URI: z.string().nonempty(), NODE_ENV: z.nativeEnum(NodeEnv), @@ -57,8 +55,6 @@ const processEnv = { GOOGLE_CLIENT_SECRET: process.env["GOOGLE_CLIENT_SECRET"], DB: IS_DEV ? "dev_calendar" : "prod_calendar", EMAILER_SECRET: process.env["EMAILER_API_SECRET"], - EMAILER_WAITLIST_TAG_ID: process.env["EMAILER_WAITLIST_TAG_ID"], - EMAILER_WAITLIST_INVITE_TAG_ID: process.env["EMAILER_WAITLIST_INVITE_TAG_ID"], EMAILER_USER_TAG_ID: process.env["EMAILER_USER_TAG_ID"], MONGO_URI: process.env["MONGO_URI"], NODE_ENV: _nodeEnv, diff --git a/packages/backend/src/common/constants/env.util.ts b/packages/backend/src/common/constants/env.util.ts index 3a7f1cc24..827f0e734 100644 --- a/packages/backend/src/common/constants/env.util.ts +++ b/packages/backend/src/common/constants/env.util.ts @@ -3,11 +3,3 @@ import { ENV } from "./env.constants"; export const isMissingUserTagId = () => { return !ENV.EMAILER_SECRET || !ENV.EMAILER_USER_TAG_ID; }; - -export const isMissingWaitlistTagId = () => { - return !ENV.EMAILER_SECRET || !ENV.EMAILER_WAITLIST_TAG_ID; -}; - -export const isMissingWaitlistInviteTagId = () => { - return !ENV.EMAILER_SECRET || !ENV.EMAILER_WAITLIST_INVITE_TAG_ID; -}; diff --git a/packages/backend/src/common/errors/waitlist/waitlist.errors.ts b/packages/backend/src/common/errors/waitlist/waitlist.errors.ts deleted file mode 100644 index 231d27054..000000000 --- a/packages/backend/src/common/errors/waitlist/waitlist.errors.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/common/services/mongo.service.ts b/packages/backend/src/common/services/mongo.service.ts index e2116b7f6..5c3dcb926 100644 --- a/packages/backend/src/common/services/mongo.service.ts +++ b/packages/backend/src/common/services/mongo.service.ts @@ -1,22 +1,21 @@ import { backOff } from "exponential-backoff"; import { - ClientSession, - ClientSessionOptions, - Collection, - ConnectionClosedEvent, - ConnectionReadyEvent, - Db, + type ClientSession, + type ClientSessionOptions, + type Collection, + type ConnectionClosedEvent, + type ConnectionReadyEvent, + type Db, MongoClient, ObjectId, } from "mongodb"; import { Logger } from "@core/logger/winston.logger"; -import { Schema_Calendar } from "@core/types/calendar.types"; -import { Schema_Event } from "@core/types/event.types"; -import { Schema_Priority } from "@core/types/priority.types"; -import { Schema_Sync } from "@core/types/sync.types"; -import { Schema_User } from "@core/types/user.types"; -import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types"; -import { Schema_Watch } from "@core/types/watch.types"; +import { type Schema_Calendar } from "@core/types/calendar.types"; +import { type Schema_Event } from "@core/types/event.types"; +import { type Schema_Priority } from "@core/types/priority.types"; +import { type Schema_Sync } from "@core/types/sync.types"; +import { type Schema_User } from "@core/types/user.types"; +import { type Schema_Watch } from "@core/types/watch.types"; import { waitUntilEvent } from "@core/util/wait-until-event.util"; import { Collections } from "@backend/common/constants/collections"; import { ENV } from "@backend/common/constants/env.constants"; @@ -31,7 +30,6 @@ interface InternalClient { priority: Collection>; sync: Collection; user: Collection; - waitlist: Collection; watch: Collection; } @@ -87,15 +85,6 @@ class MongoService { return this.#accessInternalCollectionProps("user"); } - /** - * waitlist - * - * mongo collection - */ - get waitlist(): InternalClient["waitlist"] { - return this.#accessInternalCollectionProps("waitlist"); - } - /** * watch * @@ -143,7 +132,6 @@ class MongoService { ), sync: db.collection(Collections.SYNC), user: db.collection(Collections.USER), - waitlist: db.collection(Collections.WAITLIST), watch: db.collection(Collections.WATCH), }; } diff --git a/packages/backend/src/servers/express/express.server.ts b/packages/backend/src/servers/express/express.server.ts index 0ca5fae61..79ff75371 100644 --- a/packages/backend/src/servers/express/express.server.ts +++ b/packages/backend/src/servers/express/express.server.ts @@ -1,4 +1,4 @@ -import express, { Application } from "express"; +import express, { type Application } from "express"; import helmet from "helmet"; import { errorHandler as supertokensErrorHandler, @@ -6,7 +6,7 @@ import { } from "supertokens-node/framework/express"; import { AuthRoutes } from "@backend/auth/auth.routes.config"; import { CalendarRoutes } from "@backend/calendar/calendar.routes.config"; -import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import { type CommonRoutesConfig } from "@backend/common/common.routes.config"; import corsWhitelist from "@backend/common/middleware/cors.middleware"; import { httpLoggingMiddleware } from "@backend/common/middleware/http.logger.middleware"; import { requestMiddleware } from "@backend/common/middleware/promise.middleware"; @@ -18,7 +18,6 @@ import { EventRoutes } from "@backend/event/event.routes.config"; import { PriorityRoutes } from "@backend/priority/priority.routes.config"; import { SyncRoutes } from "@backend/sync/sync.routes.config"; import { UserRoutes } from "@backend/user/user.routes.config"; -import { WaitlistRoutes } from "@backend/waitlist/waitlist.routes.config"; export const initExpressServer = () => { /* Express Configuration */ @@ -43,7 +42,6 @@ export const initExpressServer = () => { routes.push(new EventRoutes(app)); routes.push(new SyncRoutes(app)); routes.push(new CalendarRoutes(app)); - routes.push(new WaitlistRoutes(app)); app.use(supertokensErrorHandler()); // Keep this after routes diff --git a/packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts b/packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts deleted file mode 100644 index e4c795de7..000000000 --- a/packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { Express } from "express"; -import request from "supertest"; -import type { - Answers_v1, - Answers_v2, -} from "@core/types/waitlist/waitlist.answer.types"; - -describe("POST /api/waitlist", () => { - let app: Express; - let mockAddToWaitlist: jest.Mock; - - const createTestApp = async (mocks?: { - env?: Record; - service?: Record; - }) => { - if (mocks?.env) { - jest.doMock("@backend/common/constants/env.constants", () => mocks.env); - } - if (mocks?.service) { - jest.doMock("../service/waitlist.service", () => mocks.service); - } - - const { WaitlistController } = await import("./waitlist.controller"); - const express = (await import("express")).default; - const testApp = express(); - testApp.use(express.json()); - testApp.post("/api/waitlist", WaitlistController.addToWaitlist); - return testApp; - }; - - beforeEach(() => { - jest.resetModules(); - mockAddToWaitlist = jest.fn(); - }); - - it("should return 400 if answers are invalid", async () => { - app = await createTestApp({ - service: { - __esModule: true, - default: { addToWaitlist: mockAddToWaitlist }, - }, - }); - - const res = await request(app) - .post("/api/waitlist") - .send({ email: "", name: "" }); - - expect(res.status).toBe(400); - expect(res.error).toBeDefined(); - expect(mockAddToWaitlist).not.toHaveBeenCalled(); - }); - - it("should return 400 if schema version is missing", async () => { - app = await createTestApp({ - service: { - __esModule: true, - default: { addToWaitlist: mockAddToWaitlist }, - }, - }); - - const res = await request(app) - .post("/api/waitlist") - .send({ email: "test@example.com" }); - - expect(res.status).toBe(400); - expect(res.error).toBeDefined(); - expect(mockAddToWaitlist).not.toHaveBeenCalled(); - }); - - it("should return 200 if v1 answers are valid", async () => { - mockAddToWaitlist.mockResolvedValue(undefined); - app = await createTestApp({ - service: { - __esModule: true, - default: { addToWaitlist: mockAddToWaitlist }, - }, - }); - - const answers: Answers_v1 = { - email: "test@example.com", - schemaVersion: "1", - source: "social-media", - firstName: "Jo", - lastName: "Schmo", - profession: "Founder", - currentlyPayingFor: [], - anythingElse: "I'm a test", - }; - - const res = await request(app).post("/api/waitlist").send(answers); - - expect(res.status).toBe(200); - expect(res.body.error).not.toBeDefined(); - expect(mockAddToWaitlist).toHaveBeenCalledWith(answers.email, answers); - }); - - it("should return 200 if v2 answers are valid", async () => { - mockAddToWaitlist.mockResolvedValue(undefined); - app = await createTestApp({ - service: { - __esModule: true, - default: { addToWaitlist: mockAddToWaitlist }, - }, - }); - - const answers: Answers_v2 = { - email: "test@example.com", - schemaVersion: "2", - }; - - const res = await request(app).post("/api/waitlist").send(answers); - - expect(res.status).toBe(200); - expect(res.body.error).not.toBeDefined(); - expect(mockAddToWaitlist).toHaveBeenCalledWith(answers.email, answers); - }); - - it("should return 500 if emailer values are missing", async () => { - app = await createTestApp({ - env: { ENV: {} }, - service: { - __esModule: true, - default: { addToWaitlist: mockAddToWaitlist }, - }, - }); - - const answers: Answers_v1 = { - email: "test@example.com", - schemaVersion: "1", - source: "other", - firstName: "Jo", - lastName: "Schmo", - currentlyPayingFor: [], - profession: "Founder", - }; - - const res = await request(app).post("/api/waitlist").send(answers); - expect(res.status).toBe(500); - expect(res.body.error).toBe( - "Missing required emailer configuration: EMAILER_SECRET or EMAILER_WAITLIST_TAG_ID", - ); - }); -}); diff --git a/packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts b/packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts deleted file mode 100644 index 091e339c6..000000000 --- a/packages/backend/src/waitlist/controller/waitlist.controller-check.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import request from "supertest"; - -describe("GET /api/waitlist", () => { - beforeEach(() => jest.resetModules()); - it("should return 400 if email is invalid", async () => { - // Arrange - jest.doMock("../service/waitlist.service", () => ({ - __esModule: true, - default: { - isInvited: jest.fn(), - isOnWaitlist: jest.fn(), - getWaitlistRecord: jest.fn().mockResolvedValue(null), - }, - })); - 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.body).toEqual({ - isOnWaitlist: false, - isInvited: false, - isActive: false, - }); - }); - - 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 - getWaitlistRecord: jest - .fn() - .mockResolvedValue({ firstName: "Test", lastName: "User" }), - }, - })); - jest.doMock("../../user/queries/user.queries", () => ({ - __esModule: true, - findCompassUserBy: jest.fn().mockResolvedValue(null), // Simulate user not found, so isActive will be false - })); - 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 - getWaitlistRecord: jest.fn().mockResolvedValue(null), - }, - })); - jest.doMock("../../user/queries/user.queries", () => ({ - __esModule: true, - findCompassUserBy: jest.fn().mockResolvedValue(null), // Simulate user not found, so isActive will be false - })); - 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); - expect(data.isActive).toBe(false); - }); - - it("should handle case-insensitive email matching for invited users", async () => { - // Arrange - const mockIsInvited = jest.fn(); - const mockIsOnWaitlist = jest.fn(); - const mockGetWaitlistRecord = jest.fn(); - - jest.doMock("../service/waitlist.service", () => ({ - __esModule: true, - default: { - isInvited: mockIsInvited, - isOnWaitlist: mockIsOnWaitlist, - getWaitlistRecord: mockGetWaitlistRecord, - }, - })); - jest.doMock("../../user/queries/user.queries", () => ({ - __esModule: true, - findCompassUserBy: jest.fn().mockResolvedValue(null), // Simulate user not found, so isActive will be false - })); - 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); - - // Test different case variations of the same email - const testCases = [ - "FooBar@gmail.com", - "foobar@gmail.com", - "FOOBAR@GMAIL.COM", - "FooBar@Gmail.com", - ]; - - for (const emailCase of testCases) { - // Reset mocks for each test case - mockIsInvited.mockResolvedValue(true); - mockIsOnWaitlist.mockResolvedValue(true); - mockGetWaitlistRecord.mockResolvedValue({ - firstName: "Foo", - lastName: "Bar", - }); - - // Act - const res = await request(app) - .get("/api/waitlist") - .query({ email: emailCase }); - - // Assert - expect(res.status).toBe(200); - const data = res.body; - expect(data.isInvited).toBe(true); - expect(data.isOnWaitlist).toBe(true); - - // Verify that the service methods were called with the exact email case provided - expect(mockIsInvited).toHaveBeenCalledWith(emailCase.toLowerCase()); - expect(mockIsOnWaitlist).toHaveBeenCalledWith(emailCase.toLowerCase()); - } - }); - - it("should handle case-insensitive email matching for non-invited users", async () => { - // Arrange - const mockIsInvited = jest.fn(); - const mockIsOnWaitlist = jest.fn(); - const mockGetWaitlistRecord = jest.fn(); - - jest.doMock("../service/waitlist.service", () => ({ - __esModule: true, - default: { - isInvited: mockIsInvited, - isOnWaitlist: mockIsOnWaitlist, - getWaitlistRecord: mockGetWaitlistRecord, - }, - })); - jest.doMock("../../user/queries/user.queries", () => ({ - __esModule: true, - findCompassUserBy: jest.fn().mockResolvedValue(null), // Simulate user not found, so isActive will be false - })); - 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); - - // Test different case variations of the same email - const testCases = [ - "NotInvited@gmail.com", - "notinvited@gmail.com", - "NOTINVITED@GMAIL.COM", - "NotInvited@Gmail.com", - ]; - - for (const emailCase of testCases) { - // Reset mocks for each test case - mockIsInvited.mockResolvedValue(false); - mockIsOnWaitlist.mockResolvedValue(false); - mockGetWaitlistRecord.mockResolvedValue(null); - - // Act - const res = await request(app) - .get("/api/waitlist") - .query({ email: emailCase }); - - // Assert - expect(res.status).toBe(200); - const data = res.body; - expect(data.isInvited).toBe(false); - expect(data.isOnWaitlist).toBe(false); - expect(data.isActive).toBe(false); - - // Verify that the service methods were called with the exact email case provided - expect(mockIsInvited).toHaveBeenCalledWith(emailCase.toLowerCase()); - expect(mockIsOnWaitlist).toHaveBeenCalledWith(emailCase.toLowerCase()); - } - }); -}); diff --git a/packages/backend/src/waitlist/controller/waitlist.controller.ts b/packages/backend/src/waitlist/controller/waitlist.controller.ts deleted file mode 100644 index d980df328..000000000 --- a/packages/backend/src/waitlist/controller/waitlist.controller.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Request, Response } from "express"; -import { z } from "zod"; -import { BaseError } from "@core/errors/errors.base"; -import { Logger } from "@core/logger/winston.logger"; -import { - Answers, - Answers_v1, - Answers_v2, -} from "@core/types/waitlist/waitlist.answer.types"; -import { isMissingWaitlistTagId } from "@backend/common/constants/env.util"; -import { findCompassUserBy } from "../../user/queries/user.queries"; -import WaitlistService from "../service/waitlist.service"; -import { EmailSchema } from "../types/waitlist.types"; - -const logger = Logger("app:waitlist.controller"); -const WaitlistAnswerSchema = Answers.v1.or(Answers.v2); - -export class WaitlistController { - private static EmailQuerySchema = z.object({ email: EmailSchema }); - - private static handleError(err: unknown, res: Response) { - if (err instanceof BaseError) { - logger.error(err); - return res.status(err.statusCode).json({ error: err.description }); - } - if (err instanceof Error) { - logger.error(err); - return res.status(500).json({ error: err.message }); - } - logger.error("caught unknown error"); - return res.status(500).json({ error: "Server error" }); - } - - static async addToWaitlist( - req: Request, - res: Response, - ) { - if (isMissingWaitlistTagId()) { - return res.status(500).json({ - error: - "Missing required emailer configuration: EMAILER_SECRET or EMAILER_WAITLIST_TAG_ID", - }); - } - - const parseResult = WaitlistAnswerSchema.safeParse(req.body); - if (!parseResult.success) { - return res - .status(400) - .json({ error: "Invalid waitlist data", details: parseResult.error }); - } - - try { - const result = await WaitlistService.addToWaitlist( - parseResult.data.email, - parseResult.data, - ); - return res.status(200).json(result); - } catch (err) { - return WaitlistController.handleError(err, res); - } - } - - static async status( - req: Request, - res: Response<{ - isOnWaitlist: boolean; - isInvited: boolean; - isActive: boolean; - firstName?: string; - lastName?: string; - }>, - ) { - const parsed = WaitlistController.EmailQuerySchema.safeParse(req.query); - if (!parsed.success) { - logger.error("Invalid email provided for waitlist status check"); - return res.status(400).json({ - isOnWaitlist: false, - isInvited: false, - isActive: false, - }); - } - - const { email } = parsed.data; - const [isOnWaitlist, isInvited, existingUser, waitlistRecord] = - await Promise.all([ - WaitlistService.isOnWaitlist(email), - WaitlistService.isInvited(email), - findCompassUserBy("email", email), - WaitlistService.getWaitlistRecord(email), - ]); - - const isActive = !!existingUser; - const name = - waitlistRecord && "firstName" in waitlistRecord - ? { - firstName: waitlistRecord.firstName, - lastName: waitlistRecord.lastName, - } - : undefined; - - return res.status(200).json({ - isOnWaitlist, - isInvited, - isActive, - firstName: name?.firstName ?? existingUser?.firstName, - lastName: name?.lastName ?? existingUser?.lastName, - }); - } -} diff --git a/packages/backend/src/waitlist/repo/waitlist.repo.ts b/packages/backend/src/waitlist/repo/waitlist.repo.ts deleted file mode 100644 index f8df7e2f6..000000000 --- a/packages/backend/src/waitlist/repo/waitlist.repo.ts +++ /dev/null @@ -1,87 +0,0 @@ -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"; -import { getNormalizedEmail } from "../service/waitlist.service.util"; - -export class WaitlistRepository { - static async addToWaitlist(record: Schema_Waitlist) { - const normalizedEmail = getNormalizedEmail(record.email); - return mongoService.waitlist.insertOne({ - ...record, - email: normalizedEmail, - }); - } - - static async invite(email: string): Promise { - const normalizedEmail = getNormalizedEmail(email); - const isOnWaitlist = await this.isAlreadyOnWaitlist(normalizedEmail); - if (!isOnWaitlist) { - throw error(WaitlistError.NotOnWaitlist, "Email is not on waitlist"); - } - - const result = await mongoService.waitlist.updateOne( - { email: { $eq: normalizedEmail } }, - { $set: { status: "invited" } }, - ); - - const invited = result.modifiedCount === 1; - return { - status: invited ? "invited" : "ignored", - }; - } - - static async getAllWaitlisted() { - return mongoService.waitlist - .find({ status: { $eq: "waitlisted" } }) - .toArray(); - } - - static async getWaitlistRecord(email: string) { - const normalizedEmail = getNormalizedEmail(email); - return this._getWaitlistRecord(normalizedEmail); - } - - static async isAlreadyOnWaitlist(email: string) { - const normalizedEmail = getNormalizedEmail(email); - const match = await mongoService.waitlist - .find({ email: { $eq: normalizedEmail } }) - .toArray(); - return match.length > 0; - } - - static async isInvited(email: string) { - const normalizedEmail = getNormalizedEmail(email); - const record = await this._getWaitlistRecord(normalizedEmail); - - if (!record) { - return false; - } - - return record.status === "invited"; - } - - private static async _getWaitlistRecord( - email: string, - ): Promise { - // 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-add.test.ts b/packages/backend/src/waitlist/service/waitlist.service-add.test.ts deleted file mode 100644 index 79761c019..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service-add.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { Result_Waitlist } from "@core/types/waitlist/waitlist.types"; -import { EmailDriver } from "@backend/__tests__/drivers/email.driver"; -import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; -import { WaitListDriver } from "@backend/__tests__/drivers/waitlist.driver"; -import { - getEmailsOnWaitlist, - isEmailOnWaitlist, -} from "@backend/__tests__/helpers/mock.db.queries"; -import { - cleanupCollections, - cleanupTestDb, - setupTestDb, -} from "@backend/__tests__/helpers/mock.db.setup"; -import { mockEnv } from "@backend/__tests__/helpers/mock.setup"; -import WaitlistService from "@backend/waitlist/service/waitlist.service"; - -describe("addToWaitlist", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - it("should add to waitlist", async () => { - // Act - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - const result = await WaitlistService.addToWaitlist(record.email, record); - - // Assert - const expected: Result_Waitlist = { - status: "waitlisted", - }; - expect(result).toEqual(expected); - expect(await isEmailOnWaitlist(record.email)).toBe(true); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); - - it("should ignore if email is already on waitlist", async () => { - // Arrange - const { user } = await UtilDriver.setupTestUser(); - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - }); - - await WaitlistService.addToWaitlist(record.email, record); - - // Act - const result = await WaitlistService.addToWaitlist(record.email, record); - - // Assert - const expected: Result_Waitlist = { status: "ignored" }; - - expect(result).toEqual(expected); - - const emailsOnList = await getEmailsOnWaitlist(); - - const noDuplicate = - emailsOnList.filter((email) => email === user.email).length === 1; - - expect(noDuplicate).toBe(true); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); - - it("should skip emailer steps if missing EMAILER_ variables", async () => { - // Arrange - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const envSpies = mockEnv({ - EMAILER_SECRET: undefined, - EMAILER_USER_TAG_ID: undefined, - EMAILER_WAITLIST_INVITE_TAG_ID: undefined, - }); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - // Act - await WaitlistService.addToWaitlist(record.email, record); - - // Assert - expect(emailSpies.addTagToSubscriber).not.toHaveBeenCalled(); - - Object.values(emailSpies).forEach((mock) => mock.mockClear()); - Object.values(envSpies).forEach((mock) => mock.restore()); - }); -}); diff --git a/packages/backend/src/waitlist/service/waitlist.service-check.test.ts b/packages/backend/src/waitlist/service/waitlist.service-check.test.ts deleted file mode 100644 index ee6925567..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service-check.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { - cleanupCollections, - cleanupTestDb, - setupTestDb, -} from "@backend/__tests__/helpers/mock.db.setup"; -import WaitlistService from "@backend/waitlist/service/waitlist.service"; -import { EmailDriver } from "../../__tests__/drivers/email.driver"; -import { WaitListDriver } from "../../__tests__/drivers/waitlist.driver"; - -describe("isOnWaitlist", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - it("should return false if email is not waitlisted", async () => { - const result = await WaitlistService.isOnWaitlist(faker.internet.email()); - expect(result).toBe(false); - }); - - it("should return true if email is waitlisted", async () => { - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - await WaitlistService.addToWaitlist(record.email, record); - - const result = await WaitlistService.isOnWaitlist(record.email); - - expect(result).toBe(true); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); -}); diff --git a/packages/backend/src/waitlist/service/waitlist.service-getAllWaitlisted.test.ts b/packages/backend/src/waitlist/service/waitlist.service-getAllWaitlisted.test.ts deleted file mode 100644 index 941451076..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service-getAllWaitlisted.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { EmailDriver } from "@backend/__tests__/drivers/email.driver"; -import { WaitListDriver } from "@backend/__tests__/drivers/waitlist.driver"; -import { - cleanupCollections, - cleanupTestDb, - setupTestDb, -} from "@backend/__tests__/helpers/mock.db.setup"; -import WaitlistService from "@backend/waitlist/service/waitlist.service"; - -describe("getAllWaitlisted", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - it("should return all waitlisted records", async () => { - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - await WaitlistService.addToWaitlist(record.email, record); - - const records = await WaitlistService.getAllWaitlisted(); - - expect(records.length).toBeGreaterThanOrEqual(1); - const allWaitlisted = records.every((r) => r.status === "waitlisted"); - expect(allWaitlisted).toBe(true); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); -}); diff --git a/packages/backend/src/waitlist/service/waitlist.service-invite.test.ts b/packages/backend/src/waitlist/service/waitlist.service-invite.test.ts deleted file mode 100644 index 9dd3ba54a..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service-invite.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { EmailDriver } from "@backend/__tests__/drivers/email.driver"; -import { WaitListDriver } from "@backend/__tests__/drivers/waitlist.driver"; -import { - cleanupCollections, - cleanupTestDb, - setupTestDb, -} from "@backend/__tests__/helpers/mock.db.setup"; -import { mockEnv } from "@backend/__tests__/helpers/mock.setup"; -import WaitlistService from "@backend/waitlist/service/waitlist.service"; - -describe("isInvited", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - it("should return false if email is not invited", async () => { - // simulates when user was waitlisted but not invited - const result = await WaitlistService.isInvited(faker.internet.email()); - expect(result).toBe(false); - }); - - it("should return true if email is invited", async () => { - // Arrange - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - await WaitlistService.addToWaitlist(record.email, record); - await WaitlistService.invite(record.email); - - // Act - const result = await WaitlistService.isInvited(record.email); - - // Assert - expect(result).toBe(true); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); -}); - -describe("invite", () => { - beforeAll(setupTestDb); - - beforeEach(cleanupCollections); - - afterAll(cleanupTestDb); - - it("should invite email to waitlist", async () => { - // Arrange - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - await WaitlistService.addToWaitlist(record.email, record); - - // Act - const result = await WaitlistService.invite(record.email); - - // Assert - expect(result.status).toBe("invited"); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); - - it("should ignore if email is not on waitlist", async () => { - // Act - const result = await WaitlistService.invite(faker.internet.email()); - - // Assert - expect(result.status).toBe("ignored"); - }); - - it("should add tag to subscriber when inviting", async () => { - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - await WaitlistService.addToWaitlist(record.email, record); - - const result = await WaitlistService.invite(record.email); - - expect(emailSpies.addTagToSubscriber).toHaveBeenCalled(); - expect(result.tagResponse).toBeDefined(); - - emailSpies.addTagToSubscriber.mockClear(); - emailSpies.upsertSubscriber.mockClear(); - }); - - it("should skip tagging if EMAILER env vars missing", async () => { - const emailSpies = EmailDriver.mockEmailServiceResponse(); - - const envSpies = mockEnv({ - EMAILER_SECRET: undefined, - EMAILER_USER_TAG_ID: undefined, - EMAILER_WAITLIST_INVITE_TAG_ID: undefined, - }); - - const record = WaitListDriver.createWaitListRecord({ - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - }); - - await WaitlistService.addToWaitlist(record.email, record); - - const result = await WaitlistService.invite(record.email); - - expect(emailSpies.addTagToSubscriber).not.toHaveBeenCalled(); - expect(result.tagResponse).toBeUndefined(); - - Object.values(emailSpies).forEach((mock) => mock.mockClear()); - Object.values(envSpies).forEach((mock) => mock.restore()); - }); -}); diff --git a/packages/backend/src/waitlist/service/waitlist.service.ts b/packages/backend/src/waitlist/service/waitlist.service.ts deleted file mode 100644 index 20f4912b8..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Logger } from "@core/logger/winston.logger"; -import { mapWaitlistUserToEmailSubscriber } from "@core/mappers/subscriber/map.subscriber"; -import { - Answers_v1, - Answers_v2, -} from "@core/types/waitlist/waitlist.answer.types"; -import { - Result_InviteToWaitlist, - Result_Waitlist, -} from "@core/types/waitlist/waitlist.types"; -import { ENV } from "@backend/common/constants/env.constants"; -import { isMissingWaitlistInviteTagId } from "@backend/common/constants/env.util"; -import { Response_TagSubscriber } from "@backend/email/email.types"; -import EmailService from "../../email/email.service"; -import { WaitlistRepository } from "../repo/waitlist.repo"; -import { mapWaitlistAnswerToSubscriber } from "./waitlist.service.util"; - -const logger = Logger("app:waitlist.service"); - -class WaitlistService { - static async addToWaitlist( - email: string, - answer: Answers_v1 | Answers_v2, - ): Promise { - if (ENV.EMAILER_SECRET && ENV.EMAILER_WAITLIST_TAG_ID) { - const subscriber = mapWaitlistAnswerToSubscriber(email, answer); - await EmailService.addTagToSubscriber( - subscriber, - ENV.EMAILER_WAITLIST_TAG_ID, - ); - } else { - logger.warn("Did not tag subscriber due to missing EMAILER env values"); - } - - // Save to DB - const isAlreadyWaitlisted = - await WaitlistRepository.isAlreadyOnWaitlist(email); - if (isAlreadyWaitlisted) { - return { - status: "ignored", - }; - } - - await WaitlistRepository.addToWaitlist({ - ...answer, - waitlistedAt: new Date().toISOString(), - status: "waitlisted", - }); - - return { - status: "waitlisted", - }; - } - - static async invite( - email: string, - ): Promise< - Result_InviteToWaitlist & { tagResponse?: Response_TagSubscriber } - > { - try { - let tagResponse; - if (!isMissingWaitlistInviteTagId()) { - const record = await WaitlistRepository.getWaitlistRecord(email); - if (record) { - const subscriber = mapWaitlistUserToEmailSubscriber(record); - tagResponse = await EmailService.addTagToSubscriber( - subscriber, - ENV.EMAILER_WAITLIST_INVITE_TAG_ID as string, - ); - } - } else { - logger.warn("Did not tag subscriber due to missing EMAILER env values"); - } - - const result = await WaitlistRepository.invite(email); - return { ...result, tagResponse }; - } catch (error) { - logger.error("Failed to invite email to waitlist", error); - return { - status: "ignored", - }; - } - } - - static async isInvited(email: string): Promise { - return WaitlistRepository.isInvited(email); - } - - static async isOnWaitlist(email: string): Promise { - return WaitlistRepository.isAlreadyOnWaitlist(email); - } - - static async getAllWaitlisted() { - return WaitlistRepository.getAllWaitlisted(); - } - - static async getWaitlistRecord(email: string) { - return WaitlistRepository.getWaitlistRecord(email); - } -} - -export default WaitlistService; diff --git a/packages/backend/src/waitlist/service/waitlist.service.util.test.ts b/packages/backend/src/waitlist/service/waitlist.service.util.test.ts deleted file mode 100644 index fd3fe411a..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service.util.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - Answers_v1, - Answers_v2, -} from "@core/types/waitlist/waitlist.answer.types"; -import { mapWaitlistAnswerToSubscriber } from "./waitlist.service.util"; -import { getNormalizedEmail } from "./waitlist.service.util"; - -describe("getNormalizedEmail", () => { - describe("valid email normalization", () => { - it("should normalize email with mixed case and whitespace", () => { - const email = " Test@Example.COM "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("test@example.com"); - }); - - it("should normalize email with only leading whitespace", () => { - const email = " user@domain.com"; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user@domain.com"); - }); - - it("should normalize email with only trailing whitespace", () => { - const email = "user@domain.com "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user@domain.com"); - }); - - it("should normalize email with mixed case only", () => { - const email = "User@DOMAIN.com"; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user@domain.com"); - }); - - it("should normalize email with no changes needed", () => { - const email = "user@domain.com"; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user@domain.com"); - }); - - it("should normalize email with numbers and special characters", () => { - const email = " User123+tag@domain-123.co.uk "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user123+tag@domain-123.co.uk"); - }); - - it("should normalize email with subdomains", () => { - const email = " USER@sub.domain.com "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user@sub.domain.com"); - }); - }); - - describe("edge cases", () => { - it("should handle email with single character local part", () => { - const email = " A@domain.com "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("a@domain.com"); - }); - - it("should handle email with long domain", () => { - const email = - " user@very-long-domain-name-with-many-subdomains.example.co.uk "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe( - "user@very-long-domain-name-with-many-subdomains.example.co.uk", - ); - }); - - it("should handle email with underscores in local part", () => { - const email = " user_name@domain.com "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user_name@domain.com"); - }); - - it("should handle email with dots in local part", () => { - const email = " user.name@domain.com "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user.name@domain.com"); - }); - - it("should handle email with hyphens in domain", () => { - const email = " user@my-domain.com "; - const normalizedEmail = getNormalizedEmail(email); - expect(normalizedEmail).toBe("user@my-domain.com"); - }); - }); - - describe("error cases", () => { - it("should throw error for invalid email format", () => { - const invalidEmail = "not-an-email"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email without @ symbol", () => { - const invalidEmail = "userdomain.com"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email with only @ symbol", () => { - const invalidEmail = "@"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email with @ at beginning", () => { - const invalidEmail = "@domain.com"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email with @ at end", () => { - const invalidEmail = "user@"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email with multiple @ symbols", () => { - const invalidEmail = "user@domain@com"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email with spaces in local part", () => { - const invalidEmail = "user name@domain.com"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for email with spaces in domain", () => { - const invalidEmail = "user@domain .com"; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for empty string", () => { - const invalidEmail = ""; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for whitespace only", () => { - const invalidEmail = " "; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for null", () => { - const invalidEmail = null as any; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for undefined", () => { - const invalidEmail = undefined as any; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - - it("should throw error for non-string types", () => { - const invalidEmail = 123 as any; - expect(() => getNormalizedEmail(invalidEmail)).toThrow(); - }); - }); - - describe("real-world examples", () => { - it("should normalize common email formats", () => { - const testCases = [ - { input: " John.Doe@Company.COM ", expected: "john.doe@company.com" }, - { - input: "jane.smith+tag@example.org", - expected: "jane.smith+tag@example.org", - }, - { - input: " contact@my-website.co.uk ", - expected: "contact@my-website.co.uk", - }, - { input: "SUPPORT@HELPDESK.NET", expected: "support@helpdesk.net" }, - { input: " info@domain123.com ", expected: "info@domain123.com" }, - ]; - - testCases.forEach(({ input, expected }) => { - const result = getNormalizedEmail(input); - expect(result).toBe(expected); - }); - }); - }); -}); - -describe("mapWaitlistAnswerToSubscriber", () => { - it("maps v1 answers with full waitlist details", () => { - const email = "test@example.com"; - const answer: Answers_v1 = { - email, - schemaVersion: "1", - source: "search-engine", - firstName: "Ada", - lastName: "Lovelace", - profession: "Engineer", - currentlyPayingFor: ["calendar"], - anythingElse: "Early adopter", - }; - - const subscriber = mapWaitlistAnswerToSubscriber(email, answer); - - expect(subscriber).toEqual({ - email_address: email, - first_name: answer.firstName, - state: "active", - fields: { - "Last name": answer.lastName, - Birthday: "1970-01-01", - Source: answer.source, - }, - }); - }); - - it("maps v2 answers with sensible defaults", () => { - const email = "test-v2@example.com"; - const answer: Answers_v2 = { - email, - schemaVersion: "2", - }; - - const subscriber = mapWaitlistAnswerToSubscriber(email, answer); - - expect(subscriber).toEqual({ - email_address: email, - first_name: null, - state: "active", - fields: null, - }); - expect(subscriber.fields).toBeNull(); - }); - - it("sets optional fields to null for non-v1 schemas", () => { - const email = "someone@example.com"; - const answer: Answers_v2 = { - email, - schemaVersion: "2", - }; - - const subscriber = mapWaitlistAnswerToSubscriber(email, answer); - - expect(subscriber.first_name).toBeNull(); - expect(subscriber.fields).toBeNull(); - }); -}); diff --git a/packages/backend/src/waitlist/service/waitlist.service.util.ts b/packages/backend/src/waitlist/service/waitlist.service.util.ts deleted file mode 100644 index 37a648bb3..000000000 --- a/packages/backend/src/waitlist/service/waitlist.service.util.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Subscriber } from "@core/types/email/email.types"; -import { - Answers_v1, - Answers_v2, -} from "@core/types/waitlist/waitlist.answer.types"; -import { EmailSchema } from "../types/waitlist.types"; - -export const getNormalizedEmail = (email: string) => EmailSchema.parse(email); - -const DEFAULT_BIRTHDAY = "1970-01-01"; - -const isV1Answer = ( - candidate: Answers_v1 | Answers_v2, -): candidate is Answers_v1 => candidate.schemaVersion === "1"; - -export const mapWaitlistAnswerToSubscriber = ( - email: string, - answer: Answers_v1 | Answers_v2, -): Subscriber => { - if (isV1Answer(answer)) { - return { - email_address: email, - first_name: answer.firstName, - state: "active", - fields: { - "Last name": answer.lastName, - Birthday: DEFAULT_BIRTHDAY, - Source: answer.source, - }, - }; - } - - return { - email_address: email, - first_name: null, - state: "active", - fields: null, - }; -}; diff --git a/packages/backend/src/waitlist/types/waitlist.types.ts b/packages/backend/src/waitlist/types/waitlist.types.ts deleted file mode 100644 index e9d7264e1..000000000 --- a/packages/backend/src/waitlist/types/waitlist.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from "zod"; - -export const EmailSchema = z - .string() - .trim() // trim whitespace - .toLowerCase() // normalize case (emails are not case-sensitive) - .email("Invalid email address"); diff --git a/packages/backend/src/waitlist/waitlist.routes.config.ts b/packages/backend/src/waitlist/waitlist.routes.config.ts deleted file mode 100644 index 6c2d1fe2b..000000000 --- a/packages/backend/src/waitlist/waitlist.routes.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import express from "express"; -import { CommonRoutesConfig } from "@backend/common/common.routes.config"; -import { WaitlistController } from "./controller/waitlist.controller"; - -export class WaitlistRoutes extends CommonRoutesConfig { - constructor(app: express.Application) { - super(app, "WaitlistRoutes"); - } - - configureRoutes() { - this.app - .route("/api/waitlist") - .post(WaitlistController.addToWaitlist) - .get(WaitlistController.status); - return this.app; - } -} diff --git a/packages/core/src/mappers/subscriber/map.subscriber.ts b/packages/core/src/mappers/subscriber/map.subscriber.ts index f0b8261a7..67272de05 100644 --- a/packages/core/src/mappers/subscriber/map.subscriber.ts +++ b/packages/core/src/mappers/subscriber/map.subscriber.ts @@ -1,6 +1,5 @@ import { Subscriber } from "@core/types/email/email.types"; import { Schema_User } from "@core/types/user.types"; -import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types"; export const mapCompassUserToEmailSubscriber = ( user: Schema_User, @@ -25,27 +24,3 @@ export const mapCompassUserToEmailSubscriber = ( }, }; }; - -export const mapWaitlistUserToEmailSubscriber = ( - user: Schema_Waitlist, -): Subscriber => { - if (user.schemaVersion === "1") { - return { - email_address: user.email, - first_name: user.firstName, - state: "active", - fields: { - "Last name": user.lastName, - Birthday: "1970-01-01", - Source: user.source, - }, - }; - } - - return { - email_address: user.email, - first_name: null, - state: "active", - fields: null, - }; -}; diff --git a/packages/core/src/types/waitlist/waitlist.answer.types.ts b/packages/core/src/types/waitlist/waitlist.answer.types.ts deleted file mode 100644 index 527c62614..000000000 --- a/packages/core/src/types/waitlist/waitlist.answer.types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod"; - -/* v1 */ -export const Schema_Answers_v1 = z.object({ - email: z.string().email(), - schemaVersion: z.literal("1"), - source: z.enum(["search-engine", "social-media", "friend", "other"]), - firstName: z.string().min(2), - lastName: z.string().min(2), - profession: z.string().optional(), - currentlyPayingFor: z.array(z.string()).optional(), - anythingElse: z.string().optional(), -}); -export type Answers_v1 = z.infer; - -/* v2 */ -export const Schema_Answers_v2 = z.object({ - email: z.string().email(), - schemaVersion: z.literal("2"), -}); -export type Answers_v2 = z.infer; - -export const Answers = { - v2: Schema_Answers_v2, - v1: Schema_Answers_v1, -}; diff --git a/packages/core/src/types/waitlist/waitlist.types.ts b/packages/core/src/types/waitlist/waitlist.types.ts deleted file mode 100644 index 9ddf6882c..000000000 --- a/packages/core/src/types/waitlist/waitlist.types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod"; -import { Schema_Answers_v1, Schema_Answers_v2 } from "./waitlist.answer.types"; - -export interface Result_Waitlist { - status: "waitlisted" | "ignored"; -} - -export interface Result_InviteToWaitlist { - status: "invited" | "ignored"; -} - -const Schema_Status = z.enum(["waitlisted", "invited", "active"]); - -const Schema_Waitlist_v1 = Schema_Answers_v1.extend({ - status: Schema_Status, - waitlistedAt: z.string().datetime(), -}); - -const Schema_Waitlist_v2 = Schema_Answers_v2.extend({ - status: Schema_Status, - waitlistedAt: z.string().datetime(), -}); - -type Schema_Waitlist_v1 = z.infer; -type Schema_Waitlist_v2 = z.infer; -export type Schema_Waitlist = Schema_Waitlist_v1 | Schema_Waitlist_v2; diff --git a/packages/scripts/src/cli.test.ts b/packages/scripts/src/cli.test.ts index 5ae4c0c65..c1b83a9a9 100644 --- a/packages/scripts/src/cli.test.ts +++ b/packages/scripts/src/cli.test.ts @@ -1,7 +1,6 @@ import CompassCLI from "@scripts/cli"; import { runBuild } from "@scripts/commands/build"; import { startDeleteFlow } from "@scripts/commands/delete"; -import { inviteWaitlist } from "@scripts/commands/invite"; import { NodeEnv } from "../../core/src/constants/core.constants"; import { MigratorType } from "./common/cli.types"; @@ -32,7 +31,6 @@ jest.mock("@scripts/cli.validator", () => { jest.mock("@scripts/commands/build.util"); jest.mock("@scripts/commands/build"); jest.mock("@scripts/commands/delete"); -jest.mock("@scripts/commands/invite"); jest.mock("@scripts/commands/migrate", () => ({ runMigrator: jest @@ -75,16 +73,6 @@ describe("CompassCLI", () => { expect(startDeleteFlow).toHaveBeenCalledWith("user@example.com", true); }); - it("runs invite command and calls inviteWaitlist", async () => { - mockGetCliOptions.mockReturnValue({}); - - const cli = new CompassCLI(["node", "cli", "invite"]); - - await cli.run(); - - expect(inviteWaitlist).toHaveBeenCalled(); - }); - it("runs migrate command and does not throw", async () => { mockGetCliOptions.mockReturnValue({}); diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index 78a964057..970f40c31 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -4,7 +4,6 @@ import "@scripts/init"; import { CliValidator } from "@scripts/cli.validator"; import { runBuild } from "@scripts/commands/build"; import { startDeleteFlow } from "@scripts/commands/delete"; -import { inviteWaitlist } from "@scripts/commands/invite"; import { runMigrator } from "@scripts/commands/migrate"; import { ALL_PACKAGES, ENVIRONMENT } from "@scripts/common/cli.constants"; import { MigratorType } from "@scripts/common/cli.types"; @@ -36,10 +35,6 @@ export default class CompassCLI { await startDeleteFlow(user as string, force); break; } - case cmd === "invite": { - await inviteWaitlist(); - break; - } case cmd === "migrate": await runMigrator(MigratorType.MIGRATION); break; @@ -80,8 +75,6 @@ export default class CompassCLI { .option("-u, --user [id | email]", "specify which user to run script for") .option("-f, --force", "force deletion without confirmation prompts"); - program.command("invite").description("invite users from the waitlist"); - program .enablePositionalOptions(true) .passThroughOptions(true) diff --git a/packages/scripts/src/commands/invite.ts b/packages/scripts/src/commands/invite.ts deleted file mode 100644 index 5c6f90c2f..000000000 --- a/packages/scripts/src/commands/invite.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { _confirm, log } from "@scripts/common/cli.utils"; -import mongoService from "@backend/common/services/mongo.service"; -import WaitlistService from "@backend/waitlist/service/waitlist.service"; - -export const inviteWaitlist = async () => { - await mongoService.start(); - - const waitlisted = await WaitlistService.getAllWaitlisted(); - log.success(`Total on waitlist: ${waitlisted.length}`); - - if (waitlisted.length === 0) { - log.info("No users on waitlist"); - process.exit(0); - } - - for (const record of waitlisted) { - console.log(record); - const shouldInvite = await _confirm("Invite this user?"); - if (shouldInvite) { - console.log("Adding to waitlist..."); - await WaitlistService.invite(record.email); - } - } - process.exit(0); -};