Skip to content

Commit 58b4d11

Browse files
authored
✨ !Feat: create waitlist service (#411)
* 📦 deps: add supertest * ✨ feat: create waitlist and email services * ✨ feat(core): add subscriber mapper and types * ♻️ Refactor: reuse headers in email service * 🧹 Chore: delete old email service * 🧪 Test: update jest config with email alias * ✨ Feat: update user service to use new tag flow * ✨ !Feat: update ENV value to use TAG id * ✨ Feat: update waitlist service and controller * ✨ Feat: add error handling to emailer * 🧹 Chore: delete old email service * 🧹 Chore: update waitlist types * ✨ Feat: persist waitlist record * ✨ Feat: add waitlistedAt to schema * ♻️ Refactor: remove "v" from version * 🧹 Chore: update .env.example with updated KIT keys
1 parent 9c4467f commit 58b4d11

30 files changed

+877
-114
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ module.exports = {
148148
"<rootDir>/packages/backend/src/calendar/$1",
149149
"^@backend/common/(.*)$": "<rootDir>/packages/backend/src/common/$1",
150150
"^@backend/dev/(.*)$": "<rootDir>/packages/backend/src/dev/$1",
151+
"^@backend/email/(.*)$": "<rootDir>/packages/backend/src/email/$1",
151152
"^@backend/event/(.*)$": "<rootDir>/packages/backend/src/event/$1",
152153
"^@backend/priority/(.*)$":
153154
"<rootDir>/packages/backend/src/priority/$1",

packages/backend/.env.example

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,18 @@ PROD_DOMAIN=app.yourdomain.com
6666
####################################################
6767
# 6. Email (optional) #
6868
####################################################
69-
# Get these from your ConvertKit account
70-
# Does not capture email during signup if any empty EMAILER_ value
69+
# Get these from your Kit.com account
70+
# If any of these EMAILER_ values are empty, email
71+
# integration during signup will be skipped
7172

72-
EMAILER_API_SECRET=UNIQUE_SECRET_FROM_YOUR_CONVERTKIT_ACCOUNT
73-
EMAILER_LIST_ID=YOUR_LIST_ID # get this from the URL
73+
EMAILER_API_SECRET=UNIQUE_SECRET_FROM_YOUR_KIT_ACCOUNT
74+
EMAILER_TAG_ID=YOUR_TAG_ID # get this from Kit
7475

7576
####################################################
7677
# 7. Debug (optional) #
7778
####################################################
79+
# Set this to trigger socket events to a client
80+
# during development. This is helpful for testing
81+
# the sync process from local host.
82+
7883
SOCKET_USER=USER_ID_FROM_YOUR_MONGO_DB

packages/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
"@types/module-alias": "^2.0.1",
3737
"@types/morgan": "^1.9.4",
3838
"@types/node": "^22.13.10",
39+
"@types/supertest": "^6.0.3",
3940
"alias-hq": "^6.1.0",
4041
"jest-environment-node": "^29.7.0",
4142
"socket.io-client": "^4.7.5",
43+
"supertest": "^7.1.0",
4244
"tsconfig-paths": "^4.1.2"
4345
}
4446
}

packages/backend/src/__tests__/backend.test.init.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ process.env.CHANNEL_EXPIRATION_MIN = 5;
1212
process.env.SUPERTOKENS_URI = "sTUri";
1313
process.env.SUPERTOKENS_KEY = "sTKey";
1414
process.env.EMAILER_API_SECRET = "emailerApiSecret";
15-
process.env.EMAILER_LIST_ID = 1234567;
15+
process.env.EMAILER_TAG_ID = 1234567;
1616
process.env.TOKEN_GCAL_NOTIFICATION = "secretToken1";
1717
process.env.TOKEN_COMPASS_SYNC = "secretToken2";

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Event_Core } from "@core/types/event.types";
2+
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
23
import { Collections } from "@backend/common/constants/collections";
34
import mongoService from "@backend/common/services/mongo.service";
45
import { Event_API } from "@backend/common/types/backend.event.types";
@@ -20,6 +21,20 @@ export const getEventsInDb = async () => {
2021
.toArray()) as unknown as Event_API[];
2122
};
2223

24+
export const getEmailsOnWaitlist = async () => {
25+
const waitlist = (await mongoService.db
26+
.collection(Collections.WAITLIST)
27+
.find()
28+
.toArray()) as unknown as Schema_Waitlist[];
29+
30+
const emails = waitlist.map((w) => w.email);
31+
return emails;
32+
};
33+
34+
export const isEmailOnWaitlist = async (email: string) => {
35+
return (await getEmailsOnWaitlist()).includes(email);
36+
};
37+
2338
export const isEventCollectionEmpty = async () => {
2439
return (
2540
(await mongoService.db.collection(Collections.EVENT).find().toArray())

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MongoMemoryServer } from "mongodb-memory-server";
33
import { Schema_Event } from "@core/types/event.types";
44
import { Schema_Sync } from "@core/types/sync.types";
55
import { Schema_User } from "@core/types/user.types";
6+
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
67
import { Collections } from "@backend/common/constants/collections";
78
import mongoService from "@backend/common/services/mongo.service";
89

@@ -11,6 +12,7 @@ export interface TestSetup {
1112
mongoClient: MongoClient;
1213
db: Db;
1314
userId: string;
15+
email: string;
1416
}
1517

1618
jest.mock("@backend/common/middleware/supertokens.middleware", () => ({
@@ -34,19 +36,24 @@ export async function setupTestDb(): Promise<TestSetup> {
3436
await db.createCollection(Collections.USER);
3537
await db.createCollection(Collections.SYNC);
3638
await db.createCollection(Collections.EVENT);
39+
await db.createCollection(Collections.WAITLIST);
3740

3841
// Setup mongoService mock to use our test collections
3942
(mongoService as any).db = db;
4043
(mongoService as any).user = db.collection<Schema_User>(Collections.USER);
4144
(mongoService as any).sync = db.collection<Schema_Sync>(Collections.SYNC);
4245
(mongoService as any).event = db.collection<Schema_Event>(Collections.EVENT);
46+
(mongoService as any).waitlist = db.collection<Schema_Waitlist>(
47+
Collections.WAITLIST,
48+
);
4349

4450
// Create test user
4551
const userId = new ObjectId();
52+
const email = "[email protected]";
4653
const user: Schema_User = {
4754
//@ts-expect-error - overriding the _id to simulate a pre-populated collection
4855
_id: userId,
49-
56+
email,
5057
firstName: "Test",
5158
lastName: "User",
5259
name: "Test User",
@@ -83,12 +90,30 @@ export async function setupTestDb(): Promise<TestSetup> {
8390
};
8491
await mongoService.sync.insertOne(syncRecord);
8592

86-
return { mongoServer, mongoClient, db, userId: userId.toString() };
93+
// Create waitlist user
94+
await mongoService.waitlist.insertOne({
95+
email,
96+
waitlistedAt: new Date().toISOString(),
97+
schemaVersion: "0",
98+
source: "other",
99+
firstName: "Test",
100+
lastName: "User",
101+
currentlyPayingFor: ["superhuman", "notion"],
102+
howClearAboutValues: "not-clear",
103+
workingTowardsMainGoal: "yes",
104+
isWillingToShare: false,
105+
});
106+
107+
return { mongoServer, mongoClient, db, userId: userId.toString(), email };
87108
}
88109

89110
export async function cleanupCollections(db: Db): Promise<void> {
90111
const collections = await db.collections();
91-
const SKIP_COLLECTIONS = [Collections.USER, Collections.SYNC];
112+
const SKIP_COLLECTIONS = [
113+
Collections.USER,
114+
Collections.SYNC,
115+
Collections.WAITLIST,
116+
];
92117

93118
const clearPromises = collections
94119
.filter(

packages/backend/src/common/constants/collections.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export const Collections = {
77
PRIORITY: IS_DEV ? "_dev.priority" : "priority",
88
SYNC: IS_DEV ? "_dev.sync" : "sync",
99
USER: IS_DEV ? "_dev.user" : "user",
10+
WAITLIST: IS_DEV ? "_dev.waitlist" : "waitlist",
1011
};

packages/backend/src/common/constants/env.constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const EnvSchema = z
2020
CLIENT_SECRET: z.string().nonempty(),
2121
DB: z.string().nonempty(),
2222
EMAILER_SECRET: z.string().nonempty().optional(),
23-
EMAILER_LIST_ID: z.string().nonempty().optional(),
23+
EMAILER_TAG_ID: z.string().nonempty().optional(),
2424
MONGO_URI: z.string().nonempty(),
2525
NODE_ENV: z.nativeEnum(NodeEnv),
2626
ORIGINS_ALLOWED: z.array(z.string().nonempty()).default([]),
@@ -41,7 +41,7 @@ export const ENV = {
4141
CLIENT_SECRET: process.env["CLIENT_SECRET"],
4242
DB: IS_DEV ? "dev_calendar" : "prod_calendar",
4343
EMAILER_SECRET: process.env["EMAILER_API_SECRET"],
44-
EMAILER_LIST_ID: process.env["EMAILER_LIST_ID"],
44+
EMAILER_TAG_ID: process.env["EMAILER_TAG_ID"],
4545
MONGO_URI: process.env["MONGO_URI"],
4646
NODE_ENV: _nodeEnv,
4747
ORIGINS_ALLOWED: process.env["CORS"] ? process.env["CORS"].split(",") : [],

packages/backend/src/common/constants/error.constants.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { ErrorConstant } from "@core/errors/errors.base";
12
import { Status } from "@core/errors/status.codes";
23

3-
export const AuthError = {
4+
export const AuthError: ErrorConstant = {
45
DevOnly: {
56
description: "Only available during development",
67
status: Status.FORBIDDEN,
@@ -28,29 +29,15 @@ export const AuthError = {
2829
},
2930
};
3031

31-
export const DbError = {
32+
export const DbError: ErrorConstant = {
3233
InvalidId: {
3334
description: "id is invalid (according to Mongo)",
3435
status: Status.BAD_REQUEST,
3536
isOperational: true,
3637
},
3738
};
3839

39-
export const EmailerError = {
40-
IncorrectApiKey: {
41-
description:
42-
"Incorrect API key. Please make sure environment variables beginning with EMAILER_ are correct",
43-
status: Status.BAD_REQUEST,
44-
isOperational: true,
45-
},
46-
AddToListFailed: {
47-
description: "Failed to add email to list",
48-
status: Status.UNSURE,
49-
isOperational: true,
50-
},
51-
};
52-
53-
export const EventError = {
40+
export const EventError: ErrorConstant = {
5441
Gone: {
5542
description: "Resource is gone",
5643
status: Status.GONE,
@@ -88,7 +75,7 @@ export const EventError = {
8875
},
8976
};
9077

91-
export const GenericError = {
78+
export const GenericError: ErrorConstant = {
9279
BadRequest: {
9380
description: "Request is malformed",
9481
status: Status.BAD_REQUEST,
@@ -111,7 +98,7 @@ export const GenericError = {
11198
},
11299
};
113100

114-
export const GcalError = {
101+
export const GcalError: ErrorConstant = {
115102
CalendarlistMissing: {
116103
description: "No calendarlist",
117104
status: Status.BAD_REQUEST,
@@ -149,7 +136,7 @@ export const GcalError = {
149136
},
150137
};
151138

152-
export const SocketError = {
139+
export const SocketError: ErrorConstant = {
153140
InvalidSocketId: {
154141
description: "Invalid socket id",
155142
status: Status.BAD_REQUEST,
@@ -163,7 +150,7 @@ export const SocketError = {
163150
},
164151
};
165152

166-
export const SyncError = {
153+
export const SyncError: ErrorConstant = {
167154
AccessRevoked: {
168155
description: "User revoked access to their 3rd-party calendar (GCal)",
169156
status: Status.GONE,
@@ -201,7 +188,7 @@ export const SyncError = {
201188
},
202189
};
203190

204-
export const UserError = {
191+
export const UserError: ErrorConstant = {
205192
InvalidValue: {
206193
description: "User has an invalid value",
207194
status: Status.BAD_REQUEST,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Status } from "@core/errors/status.codes";
2+
import { ErrorMetadata } from "@backend/common/types/error.types";
3+
4+
interface EmailerErrorMap {
5+
InvalidTagId: ErrorMetadata;
6+
InvalidSecret: ErrorMetadata;
7+
InvalidSubscriberData: ErrorMetadata;
8+
}
9+
10+
export const EmailerError: EmailerErrorMap = {
11+
InvalidSubscriberData: {
12+
description: "Subscriber data is missing or incorrect",
13+
status: Status.BAD_REQUEST,
14+
isOperational: true,
15+
},
16+
InvalidSecret: {
17+
description:
18+
"Invalid emailer API secret. Please make sure environment variables beginning with EMAILER_ are correct",
19+
status: Status.INTERNAL_SERVER,
20+
isOperational: true,
21+
},
22+
InvalidTagId: {
23+
description:
24+
"Invalid emailer tag id. Please make sure environment variables beginning with EMAILER_ are correct",
25+
status: Status.INTERNAL_SERVER,
26+
isOperational: true,
27+
},
28+
};

packages/backend/src/common/errors/handlers/error.handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { ErrorMetadata } from "@backend/common/types/error.types";
55

66
const logger = Logger("app:error.handler");
77

8+
/**
9+
* Transforms error metadata into a BaseError class
10+
* @param cause The cause of the error
11+
* @param result The result of the error
12+
* @returns
13+
*/
814
export const error = (cause: ErrorMetadata, result: string) => {
915
return new BaseError(
1016
result,

packages/backend/src/common/services/mongo.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Logger } from "@core/logger/winston.logger";
33
import { Schema_Event } from "@core/types/event.types";
44
import { Schema_Sync } from "@core/types/sync.types";
55
import { Schema_User } from "@core/types/user.types";
6+
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
67
import { Collections } from "../constants/collections";
78
import { ENV } from "../constants/env.constants";
89

@@ -18,6 +19,7 @@ class MongoService {
1819
public event!: Collection<Schema_Event>;
1920
public sync!: Collection<Schema_Sync>;
2021
public user!: Collection<Schema_User>;
22+
public waitlist!: Collection<Schema_Waitlist>;
2123

2224
constructor() {
2325
this._connect();
@@ -35,6 +37,9 @@ class MongoService {
3537
this.event = this.db.collection<Schema_Event>(Collections.EVENT);
3638
this.sync = this.db.collection<Schema_Sync>(Collections.SYNC);
3739
this.user = this.db.collection<Schema_User>(Collections.USER);
40+
this.waitlist = this.db.collection<Schema_Waitlist>(
41+
Collections.WAITLIST,
42+
);
3843
})
3944
.catch((err) => {
4045
const retrySeconds = 5;
@@ -64,6 +69,7 @@ class MongoService {
6469
this.event = undefined as any;
6570
this.sync = undefined as any;
6671
this.user = undefined as any;
72+
this.waitlist = undefined as any;
6773
}
6874

6975
this.count = 0;

0 commit comments

Comments
 (0)