Skip to content

Commit 4cb3b31

Browse files
tyler-daneCopilot
andauthored
feat(backend): allow minimal waitlist POST data (#1072)
* feat(core): update subscriber schema to allow nullable fields object * fix(backend): update error message for missing emailer values * feat(core): add v2 schema for waitlist answers with email validation * feat(backend): add utility function to map waitlist answers to subscriber format * fix(backend): update error message for missing emailer values * feat(backend): enhance waitlist service to support v2 answer schema and update mapping utility * test(backend): add tests for waitlist controller to support v2 answer schema * test(backend): add validation tests for waitlist controller to handle missing schema version * style(web): format onChange function in FreqSelect component for better readability * Update packages/backend/src/waitlist/controller/waitlist.controller.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(backend): update error message for missing emailer configuration * fix(backend): include fields in email subscriber mapping for schema version 1 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b0b52cd commit 4cb3b31

File tree

11 files changed

+304
-99
lines changed

11 files changed

+304
-99
lines changed

packages/backend/src/__tests__/drivers/email.driver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ export class EmailDriver {
4040
},
4141
tagId: string,
4242
): Promise<Response_TagSubscriber> {
43+
const fields = subscriber.fields ?? undefined;
4344
return Promise.resolve({
4445
subscriber: {
4546
tagId,
4647
...subscriber,
4748
tagged_at: new Date().toISOString(),
49+
fields,
4850
first_name: subscriber.first_name!,
4951
},
5052
});
@@ -53,9 +55,11 @@ export class EmailDriver {
5355
private static async upsertSubscriber(
5456
subscriber: Subscriber & { id: number; created_at: string },
5557
): Promise<Response_UpsertSubscriber> {
58+
const fields = subscriber.fields ?? undefined;
5659
return Promise.resolve({
5760
subscriber: {
5861
...subscriber,
62+
fields,
5963
first_name: subscriber.first_name!,
6064
},
6165
});
Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,80 @@
1+
import type { Express } from "express";
12
import request from "supertest";
2-
import type { Answers_v1 } from "@core/types/waitlist/waitlist.answer.types";
3+
import type {
4+
Answers_v1,
5+
Answers_v2,
6+
} from "@core/types/waitlist/waitlist.answer.types";
37

48
describe("POST /api/waitlist", () => {
5-
beforeEach(() => jest.resetModules());
6-
it("should return 400 if answers are invalid", async () => {
7-
jest.doMock("../service/waitlist.service", () => ({
8-
__esModule: true,
9-
default: {
10-
addToWaitlist: jest.fn(),
11-
},
12-
}));
9+
let app: Express;
10+
let mockAddToWaitlist: jest.Mock;
11+
12+
const createTestApp = async (mocks?: {
13+
env?: Record<string, unknown>;
14+
service?: Record<string, unknown>;
15+
}) => {
16+
if (mocks?.env) {
17+
jest.doMock("@backend/common/constants/env.constants", () => mocks.env);
18+
}
19+
if (mocks?.service) {
20+
jest.doMock("../service/waitlist.service", () => mocks.service);
21+
}
22+
1323
const { WaitlistController } = await import("./waitlist.controller");
1424
const express = (await import("express")).default;
15-
const app = express();
16-
app.use(express.json());
17-
app.post("/api/waitlist", WaitlistController.addToWaitlist);
25+
const testApp = express();
26+
testApp.use(express.json());
27+
testApp.post("/api/waitlist", WaitlistController.addToWaitlist);
28+
return testApp;
29+
};
30+
31+
beforeEach(() => {
32+
jest.resetModules();
33+
mockAddToWaitlist = jest.fn();
34+
});
35+
36+
it("should return 400 if answers are invalid", async () => {
37+
app = await createTestApp({
38+
service: {
39+
__esModule: true,
40+
default: { addToWaitlist: mockAddToWaitlist },
41+
},
42+
});
1843

1944
const res = await request(app)
2045
.post("/api/waitlist")
2146
.send({ email: "", name: "" });
2247

2348
expect(res.status).toBe(400);
2449
expect(res.error).toBeDefined();
50+
expect(mockAddToWaitlist).not.toHaveBeenCalled();
2551
});
2652

27-
it("should return 200 if answers are valid", async () => {
28-
jest.doMock("../service/waitlist.service", () => ({
29-
__esModule: true,
30-
default: {
31-
addToWaitlist: jest.fn().mockResolvedValue(undefined),
53+
it("should return 400 if schema version is missing", async () => {
54+
app = await createTestApp({
55+
service: {
56+
__esModule: true,
57+
default: { addToWaitlist: mockAddToWaitlist },
3258
},
33-
}));
34-
const { WaitlistController } = await import("./waitlist.controller");
35-
const express = (await import("express")).default;
36-
const app = express();
37-
app.use(express.json());
38-
app.post("/api/waitlist", WaitlistController.addToWaitlist);
59+
});
60+
61+
const res = await request(app)
62+
.post("/api/waitlist")
63+
.send({ email: "test@example.com" });
64+
65+
expect(res.status).toBe(400);
66+
expect(res.error).toBeDefined();
67+
expect(mockAddToWaitlist).not.toHaveBeenCalled();
68+
});
69+
70+
it("should return 200 if v1 answers are valid", async () => {
71+
mockAddToWaitlist.mockResolvedValue(undefined);
72+
app = await createTestApp({
73+
service: {
74+
__esModule: true,
75+
default: { addToWaitlist: mockAddToWaitlist },
76+
},
77+
});
3978

4079
const answers: Answers_v1 = {
4180
email: "test@example.com",
@@ -47,22 +86,43 @@ describe("POST /api/waitlist", () => {
4786
currentlyPayingFor: [],
4887
anythingElse: "I'm a test",
4988
};
89+
90+
const res = await request(app).post("/api/waitlist").send(answers);
91+
92+
expect(res.status).toBe(200);
93+
expect(res.body.error).not.toBeDefined();
94+
expect(mockAddToWaitlist).toHaveBeenCalledWith(answers.email, answers);
95+
});
96+
97+
it("should return 200 if v2 answers are valid", async () => {
98+
mockAddToWaitlist.mockResolvedValue(undefined);
99+
app = await createTestApp({
100+
service: {
101+
__esModule: true,
102+
default: { addToWaitlist: mockAddToWaitlist },
103+
},
104+
});
105+
106+
const answers: Answers_v2 = {
107+
email: "test@example.com",
108+
schemaVersion: "2",
109+
};
110+
50111
const res = await request(app).post("/api/waitlist").send(answers);
51112

52113
expect(res.status).toBe(200);
53114
expect(res.body.error).not.toBeDefined();
115+
expect(mockAddToWaitlist).toHaveBeenCalledWith(answers.email, answers);
54116
});
55-
// this test is at the bottom to avoid
56-
// having to reset ENV in each test
117+
57118
it("should return 500 if emailer values are missing", async () => {
58-
jest.doMock("@backend/common/constants/env.constants", () => ({
59-
ENV: {},
60-
}));
61-
const { WaitlistController } = await import("./waitlist.controller");
62-
const express = (await import("express")).default;
63-
const app = express();
64-
app.use(express.json());
65-
app.post("/api/waitlist", WaitlistController.addToWaitlist);
119+
app = await createTestApp({
120+
env: { ENV: {} },
121+
service: {
122+
__esModule: true,
123+
default: { addToWaitlist: mockAddToWaitlist },
124+
},
125+
});
66126

67127
const answers: Answers_v1 = {
68128
email: "test@example.com",
@@ -73,8 +133,11 @@ describe("POST /api/waitlist", () => {
73133
currentlyPayingFor: [],
74134
profession: "Founder",
75135
};
136+
76137
const res = await request(app).post("/api/waitlist").send(answers);
77138
expect(res.status).toBe(500);
78-
expect(res.body.error).toBe("Emailer values are missing");
139+
expect(res.body.error).toBe(
140+
"Missing required emailer configuration: EMAILER_SECRET or EMAILER_WAITLIST_TAG_ID",
141+
);
79142
});
80143
});

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

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,61 @@ import { Request, Response } from "express";
22
import { z } from "zod";
33
import { BaseError } from "@core/errors/errors.base";
44
import { Logger } from "@core/logger/winston.logger";
5-
import { Answers } from "@core/types/waitlist/waitlist.answer.types";
6-
import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types";
5+
import {
6+
Answers,
7+
Answers_v1,
8+
Answers_v2,
9+
} from "@core/types/waitlist/waitlist.answer.types";
710
import { isMissingWaitlistTagId } from "@backend/common/constants/env.util";
811
import { findCompassUserBy } from "../../user/queries/user.queries";
912
import WaitlistService from "../service/waitlist.service";
1013
import { EmailSchema } from "../types/waitlist.types";
1114

1215
const logger = Logger("app:waitlist.controller");
16+
const WaitlistAnswerSchema = Answers.v1.or(Answers.v2);
1317

1418
export class WaitlistController {
1519
private static EmailQuerySchema = z.object({ email: EmailSchema });
1620

21+
private static handleError(err: unknown, res: Response) {
22+
if (err instanceof BaseError) {
23+
logger.error(err);
24+
return res.status(err.statusCode).json({ error: err.description });
25+
}
26+
if (err instanceof Error) {
27+
logger.error(err);
28+
return res.status(500).json({ error: err.message });
29+
}
30+
logger.error("caught unknown error");
31+
return res.status(500).json({ error: "Server error" });
32+
}
33+
1734
static async addToWaitlist(
18-
req: Request<unknown, unknown, Schema_Waitlist>,
35+
req: Request<unknown, unknown, Answers_v1 | Answers_v2>,
1936
res: Response,
2037
) {
2138
if (isMissingWaitlistTagId()) {
22-
return res.status(500).json({ error: "Emailer values are missing" });
39+
return res.status(500).json({
40+
error:
41+
"Missing required emailer configuration: EMAILER_SECRET or EMAILER_WAITLIST_TAG_ID",
42+
});
2343
}
2444

25-
const parseResult = Answers.v1.safeParse(req.body);
45+
const parseResult = WaitlistAnswerSchema.safeParse(req.body);
2646
if (!parseResult.success) {
2747
return res
2848
.status(400)
29-
.json({ error: "Invalid input", details: parseResult.error.flatten() });
49+
.json({ error: "Invalid waitlist data", details: parseResult.error });
3050
}
3151

32-
const answers = parseResult.data;
3352
try {
3453
const result = await WaitlistService.addToWaitlist(
35-
answers.email,
36-
answers,
54+
parseResult.data.email,
55+
parseResult.data,
3756
);
3857
return res.status(200).json(result);
3958
} catch (err) {
40-
// If the error is a BaseError (including EmailerError), return its status and description
41-
if (err instanceof BaseError) {
42-
logger.error(err);
43-
return res.status(err.statusCode).json({ error: err.description });
44-
}
45-
// If it's a generic Error, return its message
46-
if (err instanceof Error) {
47-
logger.error(err);
48-
return res.status(500).json({ error: err.message });
49-
}
50-
// Otherwise, return a generic server error
51-
logger.error("caught unknown error");
52-
return res.status(500).json({ error: "Server error" });
59+
return WaitlistController.handleError(err, res);
5360
}
5461
}
5562

@@ -81,14 +88,22 @@ export class WaitlistController {
8188
findCompassUserBy("email", email),
8289
WaitlistService.getWaitlistRecord(email),
8390
]);
91+
8492
const isActive = !!existingUser;
85-
const status = {
93+
const name =
94+
waitlistRecord && "firstName" in waitlistRecord
95+
? {
96+
firstName: waitlistRecord.firstName,
97+
lastName: waitlistRecord.lastName,
98+
}
99+
: undefined;
100+
101+
return res.status(200).json({
86102
isOnWaitlist,
87103
isInvited,
88104
isActive,
89-
firstName: waitlistRecord?.firstName ?? existingUser?.firstName,
90-
lastName: waitlistRecord?.lastName ?? existingUser?.lastName,
91-
};
92-
return res.status(200).json(status);
105+
firstName: name?.firstName ?? existingUser?.firstName,
106+
lastName: name?.lastName ?? existingUser?.lastName,
107+
});
93108
}
94109
}

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

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Logger } from "@core/logger/winston.logger";
22
import { mapWaitlistUserToEmailSubscriber } from "@core/mappers/subscriber/map.subscriber";
3-
import { Subscriber } from "@core/types/email/email.types";
4-
import { Answers_v1 } from "@core/types/waitlist/waitlist.answer.types";
3+
import {
4+
Answers_v1,
5+
Answers_v2,
6+
} from "@core/types/waitlist/waitlist.answer.types";
57
import {
68
Result_InviteToWaitlist,
79
Result_Waitlist,
@@ -11,24 +13,17 @@ import { isMissingWaitlistInviteTagId } from "@backend/common/constants/env.util
1113
import { Response_TagSubscriber } from "@backend/email/email.types";
1214
import EmailService from "../../email/email.service";
1315
import { WaitlistRepository } from "../repo/waitlist.repo";
16+
import { mapWaitlistAnswerToSubscriber } from "./waitlist.service.util";
1417

1518
const logger = Logger("app:waitlist.service");
19+
1620
class WaitlistService {
1721
static async addToWaitlist(
1822
email: string,
19-
answer: Answers_v1,
23+
answer: Answers_v1 | Answers_v2,
2024
): Promise<Result_Waitlist> {
2125
if (ENV.EMAILER_SECRET && ENV.EMAILER_WAITLIST_TAG_ID) {
22-
const subscriber: Subscriber = {
23-
email_address: email,
24-
first_name: answer.firstName,
25-
state: "active",
26-
fields: {
27-
"Last name": answer.lastName,
28-
Birthday: "1970-01-01",
29-
Source: answer.source,
30-
},
31-
};
26+
const subscriber = mapWaitlistAnswerToSubscriber(email, answer);
3227
await EmailService.addTagToSubscriber(
3328
subscriber,
3429
ENV.EMAILER_WAITLIST_TAG_ID,

0 commit comments

Comments
 (0)