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/__tests__/safeLogError.test.ts b/server/__tests__/safeLogError.test.ts new file mode 100644 index 00000000..fcbc8980 --- /dev/null +++ b/server/__tests__/safeLogError.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { Writable } from "node:stream"; + +import axios, { AxiosError } from "axios"; +import winston from "winston"; + +import { toSafeLogError } from "../src/utils/safeLogError.js"; + +const CANARY = "canary-newsletter-token-DO-NOT-LEAK"; + +function axiosErrorWithBearer(options: { + message: string; + code: string; + status?: number; +}): AxiosError { + const config = { + url: "/contacts/update", + method: "put", + headers: { Authorization: `Bearer ${CANARY}` }, + }; + const response = + options.status === undefined + ? undefined + : { + status: options.status, + statusText: "Unauthorized", + headers: {}, + config, + data: { message: "Invalid API key" }, + }; + + return new AxiosError( + options.message, + options.code, + config as AxiosError["config"], + undefined, + response as AxiosError["response"], + ); +} + +function serializeLikeProductionLogger(error: object): string { + let captured = ""; + const logger = winston.createLogger({ + level: "error", + format: winston.format.combine( + winston.format.timestamp({ + format: "YYYY-MM-DD HH:mm:ss", + }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json(), + ), + defaultMeta: { service: "safe-log-error-test" }, + transports: [ + new winston.transports.Stream({ + stream: new Writable({ + write(chunk, _encoding, callback) { + captured += chunk.toString(); + callback(); + }, + }), + }), + ], + }); + + logger.error({ + message: "Failed adding waitlist contact", + error, + }); + logger.close(); + return captured; +} + +describe("toSafeLogError", () => { + it("omits axios Authorization from JSON and winston output", () => { + const err = axiosErrorWithBearer({ + message: "Request failed with status code 401", + code: "ERR_BAD_REQUEST", + status: 401, + }); + assert.equal(axios.isAxiosError(err), true); + + const safe = toSafeLogError(err); + const serialized = JSON.stringify(safe); + + assert.equal(serialized.includes(CANARY), false); + assert.equal(/authorization/i.test(serialized), false); + assert.equal("config" in safe, false); + assert.equal("request" in safe, false); + assert.equal("response" in safe, false); + assert.equal("headers" in safe, false); + + const logged = serializeLikeProductionLogger(safe); + const parsed = JSON.parse(logged); + assert.equal(parsed.message, "Failed adding waitlist contact"); + assert.deepEqual(parsed.error, { + name: "AxiosError", + message: "Request failed with status code 401", + code: "ERR_BAD_REQUEST", + status: 401, + }); + assert.equal(logged.includes(CANARY), false); + assert.equal(/authorization/i.test(logged), false); + }); + + it("keeps axios message, code, and upstream status", () => { + const err = axiosErrorWithBearer({ + message: "Request failed with status code 401", + code: "ERR_BAD_REQUEST", + status: 401, + }); + + const safe = toSafeLogError(err); + + assert.equal(safe.message, "Request failed with status code 401"); + assert.equal(safe.code, "ERR_BAD_REQUEST"); + assert.equal(safe.status, 401); + assert.equal(safe.name, "AxiosError"); + }); + + it("keeps axios timeout code without a response status", () => { + const err = axiosErrorWithBearer({ + message: "timeout of 50ms exceeded", + code: "ECONNABORTED", + }); + + const safe = toSafeLogError(err); + + assert.equal(safe.message, "timeout of 50ms exceeded"); + assert.equal(safe.code, "ECONNABORTED"); + assert.equal("status" in safe, false); + assert.equal(JSON.stringify(safe).includes(CANARY), false); + }); + + it("keeps Error name, message, and code for non-axios failures", () => { + const err = Object.assign(new Error("duplicate key value"), { + code: "23505", + }); + + const safe = toSafeLogError(err); + + assert.deepEqual(safe, { + name: "Error", + message: "duplicate key value", + code: "23505", + }); + }); + + it("stringifies non-error throws", () => { + assert.deepEqual(toSafeLogError("boom"), { message: "boom" }); + }); +}); 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..e71d3f01 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,6 +9,12 @@ 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 { toSafeLogError } from "./utils/safeLogError.js"; import { Inquiry } from "./interfaces/Inquiry.js"; import Mail from "nodemailer/lib/mailer/index.js"; import axios from "axios"; @@ -30,18 +36,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 +58,31 @@ 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({ + message: "Failed adding waitlist contact", + error: toSafeLogError(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/server/src/utils/safeLogError.ts b/server/src/utils/safeLogError.ts new file mode 100644 index 00000000..f8b41464 --- /dev/null +++ b/server/src/utils/safeLogError.ts @@ -0,0 +1,47 @@ +import axios from "axios"; + +export type SafeLogError = { + name?: string; + message: string; + code?: string; + status?: number; +}; + +export function toSafeLogError(err: unknown): SafeLogError { + if (axios.isAxiosError(err)) { + const safe: SafeLogError = { + name: err.name, + message: err.message, + }; + + if (err.code !== undefined) { + safe.code = err.code; + } + + const status = err.response?.status ?? err.status; + if (typeof status === "number") { + safe.status = status; + } + + return safe; + } + + if (err instanceof Error) { + const safe: SafeLogError = { + name: err.name, + message: err.message, + }; + + if ( + "code" in err && + typeof err.code === "string" && + err.code.length > 0 + ) { + safe.code = err.code; + } + + return safe; + } + + return { message: String(err) }; +} 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);