Skip to content

Commit 231372b

Browse files
authored
✨ Feat: restrict authentication to waitlist users and contributors (#434)
* refactor: separate waitlist types and add status field to support invites * feat: add waitlist invitation functionality with tests and error handling * feat: add isInvited endpoint to check waitlist invitation status * fix: use explicit $eq operator in MongoDB queries for email matching * ✨ Add waitlist check route (#438) * ✨ feat: update waitlist route * test: remove redundant waitlist controller test * docs: fix line wrapping in README sign in instructions * ✨ Add waitlist check to sign in flow (#441) * ✨ feat: update waitlist route * test: remove redundant waitlist controller test * docs: fix line wrapping in README sign in instructions * feat: add waitlist status check and UI to login flow * refactor: streamline login flow with waitlist integration and improved error handling * feat: redesign waitlist links with vertical layout and hover effects * feat: implement waitlist status check flow with new UI components * feat: implement waitlist status check and invite-only login flow * feat: add special waitlist access for marco@polo.co and trim email inputs * feat: add special waitlist access for marco@polo.co and trim email inputs * docs: add instructions for bypassing waitlist with test email in production
1 parent c4926a5 commit 231372b

File tree

18 files changed

+883
-122
lines changed

18 files changed

+883
-122
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ All the info you'd need to get started is at [docs.compasscalendar.com](https://
6262

6363
🔵 [Production App](https://app.compasscalendar.com) (Closed beta)
6464

65+
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.
66+
67+
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.
68+
6569
🎬 [Compass on YouTube](https://youtube.com/playlist?list=PLPQAVocXPdjmYaPM9MXzplcwgoXZ_yPiJ&si=jssXj_g9kln8Iz_w)
6670

6771
[Compass Blog](https://www.compasscalendar.com/post/compass-is-open-source)

packages/backend/src/__tests__/helpers/mock.db.setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ export async function setupTestDb(): Promise<TestSetup> {
9393
// Create waitlist user
9494
await mongoService.waitlist.insertOne({
9595
email,
96-
waitlistedAt: new Date().toISOString(),
9796
schemaVersion: "0",
9897
source: "other",
9998
firstName: "Test",
@@ -102,6 +101,8 @@ export async function setupTestDb(): Promise<TestSetup> {
102101
howClearAboutValues: "not-clear",
103102
workingTowardsMainGoal: "yes",
104103
isWillingToShare: false,
104+
status: "waitlisted",
105+
waitlistedAt: new Date().toISOString(),
105106
});
106107

107108
return { mongoServer, mongoClient, db, userId: userId.toString(), email };
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Status } from "@core/errors/status.codes";
2+
import { ErrorMetadata } from "@backend/common/types/error.types";
3+
4+
interface WaitlistErrors {
5+
DuplicateEmail: ErrorMetadata;
6+
NotOnWaitlist: ErrorMetadata;
7+
}
8+
9+
export const WaitlistError: WaitlistErrors = {
10+
DuplicateEmail: {
11+
description: "Email is already on waitlist",
12+
status: Status.BAD_REQUEST,
13+
isOperational: true,
14+
},
15+
NotOnWaitlist: {
16+
description: "Email is not on waitlist",
17+
status: Status.NOT_FOUND,
18+
isOperational: true,
19+
},
20+
};

packages/backend/src/waitlist/controller/waitlist.controller.test.ts renamed to packages/backend/src/waitlist/controller/waitlist.controller-add.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import request from "supertest";
2-
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
2+
import type { Answers } from "@core/types/waitlist/waitlist.answer.types";
33

44
describe("POST /api/waitlist", () => {
55
beforeEach(() => {
@@ -41,7 +41,7 @@ describe("POST /api/waitlist", () => {
4141
app.use(express.json());
4242
app.post("/api/waitlist", WaitlistController.addToWaitlist);
4343

44-
const answers: Schema_Waitlist = {
44+
const answers: Answers = {
4545
source: "social-media",
4646
firstName: "Jo",
4747
lastName: "Schmo",
@@ -50,7 +50,6 @@ describe("POST /api/waitlist", () => {
5050
howClearAboutValues: "not-clear",
5151
workingTowardsMainGoal: "yes",
5252
isWillingToShare: false,
53-
waitlistedAt: new Date().toISOString(),
5453
schemaVersion: "0",
5554
};
5655
const res = await request(app).post("/api/waitlist").send(answers);
@@ -70,7 +69,7 @@ describe("POST /api/waitlist", () => {
7069
app.use(express.json());
7170
app.post("/api/waitlist", WaitlistController.addToWaitlist);
7271

73-
const answers: Schema_Waitlist = {
72+
const answers: Answers = {
7473
source: "other",
7574
firstName: "Jo",
7675
lastName: "Schmo",
@@ -79,7 +78,6 @@ describe("POST /api/waitlist", () => {
7978
howClearAboutValues: "not-clear",
8079
workingTowardsMainGoal: "yes",
8180
isWillingToShare: false,
82-
waitlistedAt: new Date().toISOString(),
8381
schemaVersion: "0",
8482
};
8583
const res = await request(app).post("/api/waitlist").send(answers);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import request from "supertest";
2+
3+
describe("GET /api/waitlist", () => {
4+
beforeEach(() => {
5+
jest.resetModules();
6+
jest.clearAllMocks();
7+
});
8+
9+
it("should return 400 if email is invalid", async () => {
10+
// Arrange
11+
jest.doMock("../service/waitlist.service", () => ({
12+
__esModule: true,
13+
default: {
14+
isInvited: jest.fn(),
15+
isOnWaitlist: jest.fn(),
16+
},
17+
}));
18+
const { WaitlistController } = await import("./waitlist.controller");
19+
const express = (await import("express")).default;
20+
const app = express();
21+
app.use(express.json());
22+
app.get("/api/waitlist", WaitlistController.status);
23+
24+
// Act
25+
const res = await request(app).get("/api/waitlist").query({ email: "" });
26+
27+
// Assert
28+
expect(res.status).toBe(400);
29+
expect(res.error).toBeDefined();
30+
});
31+
32+
it("should return true if email was invited", async () => {
33+
// Arrange
34+
jest.doMock("../service/waitlist.service", () => ({
35+
__esModule: true,
36+
default: {
37+
isInvited: jest.fn().mockResolvedValue(true), // user is invited
38+
isOnWaitlist: jest.fn().mockResolvedValue(true), // user is waitlisted
39+
},
40+
}));
41+
const { WaitlistController } = await import("./waitlist.controller");
42+
const express = (await import("express")).default;
43+
const app = express();
44+
app.use(express.json());
45+
app.get("/api/waitlist", WaitlistController.status);
46+
47+
// Act
48+
const res = await request(app)
49+
.get("/api/waitlist")
50+
.query({ email: "was-invited@bar.com" });
51+
52+
// Assert
53+
expect(res.status).toBe(200);
54+
const data = res.body;
55+
expect(data.isInvited).toBeDefined();
56+
expect(data.isInvited).toBe(true);
57+
});
58+
59+
it("should return false if email was not invited", async () => {
60+
// Arrange
61+
jest.doMock("../service/waitlist.service", () => ({
62+
__esModule: true,
63+
default: {
64+
isInvited: jest.fn().mockResolvedValue(false), // user is not invited
65+
isOnWaitlist: jest.fn().mockResolvedValue(false), // user is not waitlisted
66+
},
67+
}));
68+
const { WaitlistController } = await import("./waitlist.controller");
69+
const express = (await import("express")).default;
70+
const app = express();
71+
app.use(express.json());
72+
app.get("/api/waitlist", WaitlistController.status);
73+
74+
// Act
75+
const res = await request(app)
76+
.get("/api/waitlist")
77+
.query({ email: "not-invited@bar.com" });
78+
79+
// Assert
80+
expect(res.status).toBe(200);
81+
const data = res.body;
82+
expect(data.isInvited).toBeDefined();
83+
expect(data.isInvited).toBe(false);
84+
expect(data.isOnWaitlist).toBe(false);
85+
});
86+
});

packages/backend/src/waitlist/controller/waitlist.controller.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { Request, Response } from "express";
22
import { BaseError } from "@core/errors/errors.base";
33
import { Logger } from "@core/logger/winston.logger";
4-
import {
5-
AnswerMap,
6-
Schema_Waitlist,
7-
} from "@core/types/waitlist/waitlist.types";
4+
import { AnswerMap } from "@core/types/waitlist/waitlist.answer.types";
5+
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
86
import { isMissingWaitlistTagId } from "@backend/common/constants/env.util";
97
import WaitlistService from "../service/waitlist.service";
108

@@ -49,4 +47,50 @@ export class WaitlistController {
4947
return res.status(500).json({ error: "Server error" });
5048
}
5149
}
50+
51+
static async isInvited(
52+
req: Request<unknown, unknown, unknown, { email: string }>,
53+
res: Response<{ isInvited: boolean }>,
54+
) {
55+
const email = req.query.email;
56+
if (!email) {
57+
logger.error("Could not check if invited due to missing request email");
58+
return res.status(400).json({
59+
isInvited: false,
60+
});
61+
}
62+
63+
const isInvited = await WaitlistService.isInvited(email);
64+
return res.status(200).json({ isInvited });
65+
}
66+
67+
static async isOnWaitlist(
68+
req: Request<unknown, unknown, unknown, { email: string }>,
69+
res: Response<{ isOnWaitlist: boolean }>,
70+
) {
71+
const email = req.query.email;
72+
if (!email) {
73+
logger.error("Could not check waitlist due to missing request email");
74+
return res.status(400).json({ isOnWaitlist: false });
75+
}
76+
77+
const isOnWaitlist = await WaitlistService.isOnWaitlist(email);
78+
return res.status(200).json({ isOnWaitlist });
79+
}
80+
static async status(
81+
req: Request<unknown, unknown, unknown, { email: string }>,
82+
res: Response<{ isOnWaitlist: boolean; isInvited: boolean }>,
83+
) {
84+
const email = req.query.email;
85+
if (!email) {
86+
logger.error("Could not check waitlist due to missing request email");
87+
return res.status(400).json({ isOnWaitlist: false, isInvited: false });
88+
}
89+
90+
const [isOnWaitlist, isInvited] = await Promise.all([
91+
WaitlistService.isOnWaitlist(email),
92+
WaitlistService.isInvited(email),
93+
]);
94+
return res.status(200).json({ isOnWaitlist, isInvited });
95+
}
5296
}
Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,68 @@
1-
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
1+
import {
2+
Result_InviteToWaitlist,
3+
Schema_Waitlist,
4+
} from "@core/types/waitlist/waitlist.types";
5+
import { error } from "@backend/common/errors/handlers/error.handler";
6+
import { WaitlistError } from "@backend/common/errors/waitlist/waitlist.errors";
27
import mongoService from "@backend/common/services/mongo.service";
38

49
export class WaitlistRepository {
510
static async addToWaitlist(record: Schema_Waitlist) {
611
return mongoService.waitlist.insertOne(record);
712
}
813

14+
static async invite(email: string): Promise<Result_InviteToWaitlist> {
15+
const isOnWaitlist = await this.isAlreadyOnWaitlist(email);
16+
if (!isOnWaitlist) {
17+
throw error(WaitlistError.NotOnWaitlist, "Email is not on waitlist");
18+
}
19+
20+
const result = await mongoService.waitlist.updateOne(
21+
{ email: { $eq: email } },
22+
{ $set: { status: "invited" } },
23+
);
24+
25+
const invited = result.modifiedCount === 1;
26+
return {
27+
status: invited ? "invited" : "ignored",
28+
};
29+
}
30+
931
static async isAlreadyOnWaitlist(email: string) {
10-
const match = await mongoService.waitlist.find({ email }).toArray();
32+
const match = await mongoService.waitlist
33+
.find({ email: { $eq: email } })
34+
.toArray();
1135
return match.length > 0;
1236
}
37+
38+
static async isInvited(email: string) {
39+
const record = await this._getWaitlistRecord(email);
40+
41+
if (!record) {
42+
return false;
43+
}
44+
45+
return record.status === "invited";
46+
}
47+
48+
private static async _getWaitlistRecord(
49+
email: string,
50+
): Promise<Schema_Waitlist | null> {
51+
// Fetch up to 2 records to efficiently check for duplicates.
52+
const matches = await mongoService.waitlist
53+
.find({ email: { $eq: email } })
54+
.limit(2)
55+
.toArray();
56+
57+
if (matches.length > 1) {
58+
throw error(WaitlistError.DuplicateEmail, "Unique email not returned");
59+
}
60+
61+
if (matches.length === 0) {
62+
return null; // No waitlist entry found for this email
63+
}
64+
65+
// Exactly one match found
66+
return matches[0] as Schema_Waitlist;
67+
}
1368
}

packages/backend/src/waitlist/service/waitlist.service.test.ts renamed to packages/backend/src/waitlist/service/waitlist.service-add.test.ts

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import {
2-
Result_Waitlist,
3-
Schema_Waitlist,
4-
} from "@core/types/waitlist/waitlist.types";
1+
import { Result_Waitlist } from "@core/types/waitlist/waitlist.types";
52
import {
63
getEmailsOnWaitlist,
74
isEmailOnWaitlist,
@@ -11,52 +8,8 @@ import {
118
cleanupTestMongo,
129
setupTestDb,
1310
} from "@backend/__tests__/helpers/mock.db.setup";
14-
import EmailService from "../../email/email.service";
1511
import WaitlistService from "./waitlist.service";
16-
17-
const answer: Schema_Waitlist = {
18-
firstName: "Jo",
19-
lastName: "Schmo",
20-
source: "other",
21-
email: "joe@schmo.com",
22-
currentlyPayingFor: undefined,
23-
howClearAboutValues: "not-clear",
24-
workingTowardsMainGoal: "yes",
25-
isWillingToShare: false,
26-
waitlistedAt: new Date().toISOString(),
27-
schemaVersion: "0",
28-
};
29-
30-
// Mock emailer API calls
31-
jest.spyOn(EmailService, "upsertSubscriber").mockResolvedValue({
32-
subscriber: {
33-
id: 1,
34-
first_name: answer.firstName,
35-
email_address: answer.email,
36-
state: "active",
37-
created_at: new Date().toISOString(),
38-
fields: {
39-
"Last name": answer.lastName,
40-
Birthday: "1970-01-01",
41-
Source: answer.source,
42-
},
43-
},
44-
});
45-
jest.spyOn(EmailService, "addTagToSubscriber").mockResolvedValue({
46-
subscriber: {
47-
id: 1,
48-
first_name: answer.firstName,
49-
email_address: answer.email,
50-
state: "active",
51-
created_at: new Date().toISOString(),
52-
tagged_at: new Date().toISOString(),
53-
fields: {
54-
"Last name": answer.lastName,
55-
Birthday: "1970-01-01",
56-
Source: answer.source,
57-
},
58-
},
59-
});
12+
import { answer } from "./waitlist.service.test-setup";
6013

6114
describe("addToWaitlist", () => {
6215
let setup: Awaited<ReturnType<typeof setupTestDb>>;

0 commit comments

Comments
 (0)