Skip to content

Commit aa2a59c

Browse files
committed
feat: add waitlist invitation functionality with tests and error handling
1 parent e03cac6 commit aa2a59c

File tree

7 files changed

+221
-51
lines changed

7 files changed

+221
-51
lines changed
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+
};
Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,66 @@
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 },
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) {
1032
const match = await mongoService.waitlist.find({ email }).toArray();
1133
return match.length > 0;
1234
}
1335

14-
//TODO change to include an 'invited' status
1536
static async isInvited(email: string) {
16-
const match = await mongoService.waitlist.find({ email }).toArray();
17-
return match.length > 0;
37+
const record = await this._getWaitlistRecord(email);
38+
39+
if (!record) {
40+
return false;
41+
}
42+
43+
return record.status === "invited";
44+
}
45+
46+
private static async _getWaitlistRecord(
47+
email: string,
48+
): Promise<Schema_Waitlist | null> {
49+
// Fetch up to 2 records to efficiently check for duplicates.
50+
const matches = await mongoService.waitlist
51+
.find({ email })
52+
.limit(2)
53+
.toArray();
54+
55+
if (matches.length > 1) {
56+
throw error(WaitlistError.DuplicateEmail, "Unique email not returned");
57+
}
58+
59+
if (matches.length === 0) {
60+
return null; // No waitlist entry found for this email
61+
}
62+
63+
// Exactly one match found
64+
return matches[0] as Schema_Waitlist;
1865
}
1966
}

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

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

5814
describe("addToWaitlist", () => {
5915
let setup: Awaited<ReturnType<typeof setupTestDb>>;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
cleanupCollections,
3+
cleanupTestMongo,
4+
setupTestDb,
5+
} from "@backend/__tests__/helpers/mock.db.setup";
6+
import WaitlistService from "./waitlist.service";
7+
import { answer } from "./waitlist.service.test-setup";
8+
9+
describe("isInvited", () => {
10+
let setup: Awaited<ReturnType<typeof setupTestDb>>;
11+
12+
beforeAll(async () => {
13+
setup = await setupTestDb();
14+
});
15+
16+
beforeEach(async () => {
17+
await cleanupCollections(setup.db);
18+
});
19+
20+
afterAll(async () => {
21+
await cleanupTestMongo(setup);
22+
});
23+
24+
it("should return false if email is not invited", async () => {
25+
// simulates when user was waitlisted but not invited
26+
const result = await WaitlistService.isInvited(answer.email);
27+
expect(result).toBe(false);
28+
});
29+
30+
it("should return true if email is invited", async () => {
31+
// Arrange
32+
await WaitlistService.addToWaitlist(answer.email, answer);
33+
await WaitlistService.invite(answer.email);
34+
35+
// Act
36+
const result = await WaitlistService.isInvited(answer.email);
37+
38+
// Assert
39+
expect(result).toBe(true);
40+
});
41+
});
42+
43+
describe("invite", () => {
44+
let setup: Awaited<ReturnType<typeof setupTestDb>>;
45+
46+
beforeAll(async () => {
47+
setup = await setupTestDb();
48+
});
49+
50+
beforeEach(async () => {
51+
await cleanupCollections(setup.db);
52+
});
53+
54+
afterAll(async () => {
55+
await cleanupTestMongo(setup);
56+
});
57+
58+
it("should invite email to waitlist", async () => {
59+
// Arrange
60+
await WaitlistService.addToWaitlist(answer.email, answer);
61+
62+
// Act
63+
const result = await WaitlistService.invite(answer.email);
64+
65+
// Assert
66+
expect(result.status).toBe("invited");
67+
});
68+
69+
it("should ignore if email is not on waitlist", async () => {
70+
// Act
71+
const result = await WaitlistService.invite(answer.email);
72+
73+
// Assert
74+
expect(result.status).toBe("ignored");
75+
});
76+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Answers } from "@core/types/waitlist/waitlist.answer.types";
2+
import EmailService from "@backend/email/email.service";
3+
4+
/**
5+
* Create mocks for waitlist service tests
6+
*/
7+
8+
export const answer: Answers = {
9+
firstName: "Jo",
10+
lastName: "Schmo",
11+
source: "other",
12+
email: "joe@schmo.com",
13+
currentlyPayingFor: undefined,
14+
howClearAboutValues: "not-clear",
15+
workingTowardsMainGoal: "yes",
16+
isWillingToShare: false,
17+
schemaVersion: "0",
18+
};
19+
20+
// Mock emailer API calls
21+
jest.spyOn(EmailService, "upsertSubscriber").mockResolvedValue({
22+
subscriber: {
23+
id: 1,
24+
first_name: answer.firstName,
25+
email_address: answer.email,
26+
state: "active",
27+
created_at: new Date().toISOString(),
28+
fields: {
29+
"Last name": answer.lastName,
30+
Birthday: "1970-01-01",
31+
Source: answer.source,
32+
},
33+
},
34+
});
35+
jest.spyOn(EmailService, "addTagToSubscriber").mockResolvedValue({
36+
subscriber: {
37+
id: 1,
38+
first_name: answer.firstName,
39+
email_address: answer.email,
40+
state: "active",
41+
created_at: new Date().toISOString(),
42+
tagged_at: new Date().toISOString(),
43+
fields: {
44+
"Last name": answer.lastName,
45+
Birthday: "1970-01-01",
46+
Source: answer.source,
47+
},
48+
},
49+
});

packages/backend/src/waitlist/service/waitlist.service.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Logger } from "@core/logger/winston.logger";
22
import { Subscriber } from "@core/types/email/email.types";
3+
import { Answers } from "@core/types/waitlist/waitlist.answer.types";
34
import {
5+
Result_InviteToWaitlist,
46
Result_Waitlist,
5-
Schema_Answers,
67
} from "@core/types/waitlist/waitlist.types";
78
import { ENV } from "@backend/common/constants/env.constants";
89
import EmailService from "../../email/email.service";
@@ -12,7 +13,7 @@ const logger = Logger("app:waitlist.service");
1213
class WaitlistService {
1314
static async addToWaitlist(
1415
email: string,
15-
answer: Schema_Answers,
16+
answer: Answers,
1617
): Promise<Result_Waitlist> {
1718
if (ENV.EMAILER_SECRET && ENV.EMAILER_WAITLIST_TAG_ID) {
1819
const subscriber: Subscriber = {
@@ -45,12 +46,29 @@ class WaitlistService {
4546
await WaitlistRepository.addToWaitlist({
4647
...answer,
4748
waitlistedAt: new Date().toISOString(),
49+
status: "waitlisted",
4850
});
4951

5052
return {
5153
status: "waitlisted",
5254
};
5355
}
56+
57+
static async invite(email: string): Promise<Result_InviteToWaitlist> {
58+
try {
59+
const result = await WaitlistRepository.invite(email);
60+
return result;
61+
} catch (error) {
62+
logger.error("Failed to invite email to waitlist", error);
63+
return {
64+
status: "ignored",
65+
};
66+
}
67+
}
68+
69+
static async isInvited(email: string): Promise<boolean> {
70+
return WaitlistRepository.isInvited(email);
71+
}
5472
}
5573

5674
export default WaitlistService;

packages/core/src/types/waitlist/waitlist.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export interface Result_Waitlist {
55
status: "waitlisted" | "ignored";
66
}
77

8+
export interface Result_InviteToWaitlist {
9+
status: "invited" | "ignored";
10+
}
11+
812
const Schema_Status = z.enum(["waitlisted", "invited", "active"]);
913

1014
const Schema_Waitlist_v0 = Schema_Answers_v0.extend({

0 commit comments

Comments
 (0)