From 6102fd93d484feeb86baa84c177bce5ea694f281 Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 28 Aug 2026 10:47:38 +0800 Subject: [PATCH 1/2] feat: add loops --- server/__tests__/loopsContact.test.ts | 88 +++++++++++++++++++ server/package.json | 1 + server/src/app.ts | 48 +++++++--- server/src/config/index.ts | 4 + server/src/utils/loopsContact.ts | 80 +++++++++++++++++ website/src/api/client.ts | 4 +- .../features/home/NewsletterSection.astro | 2 + website/src/components/ui/SubscribeForm.astro | 2 + website/src/constants/waitlist.ts | 11 +-- 9 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 server/__tests__/loopsContact.test.ts create mode 100644 server/src/utils/loopsContact.ts diff --git a/server/__tests__/loopsContact.test.ts b/server/__tests__/loopsContact.test.ts new file mode 100644 index 00000000..779392db --- /dev/null +++ b/server/__tests__/loopsContact.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + MissingMailingListIdError, + UnknownWaitlistSourceError, + WAITLIST_SOURCE, + buildLoopsContactPayload, +} from "../src/utils/loopsContact.js"; + +const listIds = { + newsletter: "list_newsletter", + launch: "list_launch", +}; + +describe("buildLoopsContactPayload", () => { + it("puts omitted source on the newsletter list with camelCase names", () => { + const payload = buildLoopsContactPayload( + { + email: "alex@company.com", + firstName: "Alex", + lastName: "Rivera", + }, + listIds, + ); + + assert.deepEqual(payload, { + email: "alex@company.com", + firstName: "Alex", + lastName: "Rivera", + source: WAITLIST_SOURCE.NEWSLETTER, + mailingLists: { list_newsletter: true }, + }); + }); + + it("puts launch source on the launch waitlist", () => { + const payload = buildLoopsContactPayload( + { + email: "sam@company.com", + source: WAITLIST_SOURCE.LAUNCH, + }, + listIds, + ); + + assert.deepEqual(payload, { + email: "sam@company.com", + source: WAITLIST_SOURCE.LAUNCH, + mailingLists: { list_launch: true }, + }); + }); + + it("omits empty names so a launch signup does not wipe existing Loops names", () => { + const payload = buildLoopsContactPayload( + { + email: "sam@company.com", + firstName: "", + lastName: "", + source: WAITLIST_SOURCE.LAUNCH, + }, + listIds, + ); + + assert.equal("firstName" in payload, false); + assert.equal("lastName" in payload, false); + }); + + it("rejects an unknown source", () => { + assert.throws( + () => + buildLoopsContactPayload( + { email: "alex@company.com", source: "sponsors" }, + listIds, + ), + UnknownWaitlistSourceError, + ); + }); + + it("fails when the mailing list ID for that source is missing", () => { + assert.throws( + () => + buildLoopsContactPayload( + { email: "alex@company.com", source: WAITLIST_SOURCE.LAUNCH }, + { newsletter: "list_newsletter" }, + ), + MissingMailingListIdError, + ); + }); +}); diff --git a/server/package.json b/server/package.json index 34ed539b..88c02add 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "db:startover": "rimraf src/models/migration && npm run build && npm run db:generate && node dist/src/models/scripts/startover.js", "db:import": "node dist/src/models/scripts/import-data.js src/models/contacts.csv", "db:export": "node dist/src/models/scripts/export-data.js", + "test": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json && node --test dist/__tests__/*.js", "postinstall": "node ./drizzle-pg-timestamp-patch.js" }, "devDependencies": { diff --git a/server/src/app.ts b/server/src/app.ts index f9c303c9..f09a5754 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,6 +9,11 @@ import db from "./config/db.js"; import emailTransporter from "./config/emailTransporter.js"; import { waitlist } from "./models/waitlist.js"; import { generateUniqueID } from "./utils/generateId.js"; +import { + MissingMailingListIdError, + UnknownWaitlistSourceError, + buildLoopsContactPayload, +} from "./utils/loopsContact.js"; import { Inquiry } from "./interfaces/Inquiry.js"; import Mail from "nodemailer/lib/mailer/index.js"; import axios from "axios"; @@ -30,18 +35,21 @@ app.get("/", async (_, res) => { res.send("API is running..."); }); app.post("/api/waitlist", async (req, res) => { - const { email, firstName, lastName } = req.body; - if (!email) res.status(400).json({ error: "Email is required!" }); - if (!firstName) res.status(400).json({ error: "First name is required!" }); + const { email, firstName, lastName, source } = req.body; + if (!email) { + res.status(400).json({ error: "Email is required!" }); + return; + } try { - await dbClient - .insert(waitlist) - .values({ id: generateUniqueID(), email, lastName, firstName }); + const loopsContact = buildLoopsContactPayload( + { email, firstName, lastName, source }, + env.newsletter.mailingListIds, + ); - await axios.post( - `${env.newsletter.baseUrl}/subscribers`, - { email, lastname: lastName, firstname: firstName }, + await axios.put( + `${env.newsletter.baseUrl}/contacts/update`, + loopsContact, { headers: { Authorization: `Bearer ${env.newsletter.apiToken}`, @@ -49,16 +57,28 @@ app.post("/api/waitlist", async (req, res) => { }, ); + try { + await dbClient + .insert(waitlist) + .values({ id: generateUniqueID(), email, lastName, firstName }); + } catch (err) { + if (!(err instanceof DatabaseError && err.code === "23505")) { + throw err; + } + } + res.status(201).json({ message: "Success adding to newsletter.", email }); } catch (err) { - if (err instanceof DatabaseError && err.code === "23505") { - const column = err.detail?.split(")=")[0]?.slice(5); - - res.status(409).json({ error: `${column} is already used.` }); - + if (err instanceof UnknownWaitlistSourceError) { + res.status(400).json({ error: err.message }); + return; + } + if (err instanceof MissingMailingListIdError) { + res.status(500).json({ error: err.message }); return; } + logger.error("Failed adding waitlist contact", err); res.status(500).json({ error: "Unknown internal server error" }); } }); diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 39c692b3..0ad7fe58 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -40,5 +40,9 @@ export default { newsletter: { baseUrl: process.env.NEWSLETTER_BASE_URL, apiToken: process.env.NEWSLETTER_API_TOKEN, + mailingListIds: { + newsletter: process.env.NEWSLETTER_MAILING_LIST_ID, + launch: process.env.LAUNCH_MAILING_LIST_ID, + }, }, }; diff --git a/server/src/utils/loopsContact.ts b/server/src/utils/loopsContact.ts new file mode 100644 index 00000000..61f2bb45 --- /dev/null +++ b/server/src/utils/loopsContact.ts @@ -0,0 +1,80 @@ +export const WAITLIST_SOURCE = { + NEWSLETTER: "newsletter", + LAUNCH: "launch", +} as const; + +export type WaitlistSource = + (typeof WAITLIST_SOURCE)[keyof typeof WAITLIST_SOURCE]; + +const WAITLIST_SOURCES = new Set(Object.values(WAITLIST_SOURCE)); + +export class UnknownWaitlistSourceError extends Error { + constructor(source: string) { + super(`Unknown waitlist source: ${source}`); + this.name = "UnknownWaitlistSourceError"; + } +} + +export class MissingMailingListIdError extends Error { + constructor(source: WaitlistSource) { + super(`Missing Loops mailing list ID for source "${source}"`); + this.name = "MissingMailingListIdError"; + } +} + +export type LoopsMailingListIds = { + newsletter?: string; + launch?: string; +}; + +export type LoopsContactPayload = { + email: string; + source: WaitlistSource; + mailingLists: Record; + firstName?: string; + lastName?: string; +}; + +function resolveWaitlistSource(source: string | null | undefined): WaitlistSource { + if (source == null) { + return WAITLIST_SOURCE.NEWSLETTER; + } + + if (!WAITLIST_SOURCES.has(source)) { + throw new UnknownWaitlistSourceError(source); + } + + return source as WaitlistSource; +} + +export function buildLoopsContactPayload( + contact: { + email: string; + firstName?: string; + lastName?: string; + source?: string | null; + }, + mailingListIds: LoopsMailingListIds, +): LoopsContactPayload { + const source = resolveWaitlistSource(contact.source); + const listId = mailingListIds[source]; + + if (!listId) { + throw new MissingMailingListIdError(source); + } + + const payload: LoopsContactPayload = { + email: contact.email, + source, + mailingLists: { [listId]: true }, + }; + + if (contact.firstName) { + payload.firstName = contact.firstName; + } + if (contact.lastName) { + payload.lastName = contact.lastName; + } + + return payload; +} diff --git a/website/src/api/client.ts b/website/src/api/client.ts index d383e8b8..0f3933ff 100644 --- a/website/src/api/client.ts +++ b/website/src/api/client.ts @@ -111,8 +111,8 @@ const createApiClient = () => { /** * Subscribe to waitlist * - * `source` identifies which list the signup belongs to. Omitting it - * produces the original payload, so existing callers are unaffected. + * `source` identifies which Loops mailing list the signup belongs to. + * Omitting it is treated as newsletter by the API. */ subscribe: ( email: string, diff --git a/website/src/components/features/home/NewsletterSection.astro b/website/src/components/features/home/NewsletterSection.astro index c04014fd..4fb94e84 100644 --- a/website/src/components/features/home/NewsletterSection.astro +++ b/website/src/components/features/home/NewsletterSection.astro @@ -310,6 +310,7 @@ const t = await createTranslator(locale);