From 7e844c646da7e5d1e2c4a8d6ba2d4cfd641f76b4 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 16:35:45 +0200 Subject: [PATCH] feat(marketing-pages): sendWelcomeEmail job + handler enqueues it sendWelcomeEmailJob takes IMailerService, validates the typed input (userId + email), and delegates to mailer.sendWelcome. The onAuthUserSignedUpHandler now takes IJobQueue and enqueues "marketing-pages.send-welcome-email" with the event payload. Both binders bind a RecordingMailerService at the IMailerService symbol (production placeholder until a real adapter ships) and pass mailer / queue into the wrapped factories. Dev-seed additionally queue.register()s the slug so the in-memory queue dispatches via the wrapped job; production relies on the generated Payload event-task to resolve the wrapped handler from the container. --- .../marketing-pages/src/di/bind-dev-seed.ts | 49 +++++++++++++++++-- .../marketing-pages/src/di/bind-production.ts | 37 ++++++++++++-- packages/marketing-pages/src/di/symbols.ts | 1 + .../on-auth-user-signed-up.handler.test.ts | 24 ++++++--- .../on-auth-user-signed-up.handler.ts | 12 +++-- .../src/integrations/cms/index.ts | 1 + .../cms/jobs/send-welcome-email.task.ts | 18 +++++++ .../src/jobs/send-welcome-email.job.test.ts | 37 ++++++++++++++ .../src/jobs/send-welcome-email.job.ts | 20 ++++++++ 9 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts create mode 100644 packages/marketing-pages/src/jobs/send-welcome-email.job.test.ts create mode 100644 packages/marketing-pages/src/jobs/send-welcome-email.job.ts diff --git a/packages/marketing-pages/src/di/bind-dev-seed.ts b/packages/marketing-pages/src/di/bind-dev-seed.ts index a25c7fa..860c7c2 100644 --- a/packages/marketing-pages/src/di/bind-dev-seed.ts +++ b/packages/marketing-pages/src/di/bind-dev-seed.ts @@ -19,6 +19,9 @@ import type { IPagesRepository } from "../application/repositories/pages.reposit import type { ISiteSettingsRepository } from "../application/repositories/site-settings.repository.interface.js"; import { userSignedUpEvent } from "@repo/auth"; import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler.js"; +import { sendWelcomeEmailJob, type ISendWelcomeEmailJob, type SendWelcomeEmailInput } from "../jobs/send-welcome-email.job.js"; +import { RecordingMailerService } from "../infrastructure/services/recording-mailer.service.js"; +import type { IMailerService } from "../application/services/mailer.service.interface.js"; /** * Replace the default empty mocks with populated ones for dev mode + storybook. @@ -129,10 +132,15 @@ export async function bindDevSeedMarketingPages( ), ), ); - // bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator - // output at the / anchors below. - void bus; - void queue; + // Bind the dev-seed mailer (recording so e2e tests can inspect calls). + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IMailerService)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IMailerService); + } + const mailer = new RecordingMailerService(); + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.IMailerService) + .toConstantValue(mailer); + // // onAuthUserSignedUpHandler subscription — generated, edit the handler file (not this block) for behavior. const wrappedAuthUserSignedUp = withSpan( @@ -145,7 +153,7 @@ export async function bindDevSeedMarketingPages( layer: "event-handler", name: "marketing-pages.onAuthUserSignedUpHandler", }, - onAuthUserSignedUpHandler(), + onAuthUserSignedUpHandler(queue), ), ); if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IOnAuthUserSignedUpHandler)) { @@ -154,4 +162,35 @@ export async function bindDevSeedMarketingPages( marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.IOnAuthUserSignedUpHandler).toConstantValue(wrappedAuthUserSignedUp); bus.subscribe(userSignedUpEvent, "marketing-pages", wrappedAuthUserSignedUp); // + const wrappedSendWelcomeEmail = withSpan( + tracer, + { name: "marketing-pages.sendWelcomeEmail", op: "job" }, + withCapture( + logger, + { + feature: "marketing-pages", + layer: "job", + name: "marketing-pages.sendWelcomeEmail", + }, + sendWelcomeEmailJob(mailer), + ), + ); + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob); + } + marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob).toConstantValue(wrappedSendWelcomeEmail); + + // Register the dev-seed in-memory queue handler so enqueue() actually fires + // the wrapped job. Production binding skips this — the Payload task does it. + if ("register" in queue && typeof (queue as { register?: unknown }).register === "function") { + (queue as { register: (slug: string, h: (input: unknown) => Promise) => void }).register( + "marketing-pages.send-welcome-email", + async (input) => { + const wrapped = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, + ); + await wrapped(input as SendWelcomeEmailInput); + }, + ); + } } diff --git a/packages/marketing-pages/src/di/bind-production.ts b/packages/marketing-pages/src/di/bind-production.ts index add8704..e61bfb0 100644 --- a/packages/marketing-pages/src/di/bind-production.ts +++ b/packages/marketing-pages/src/di/bind-production.ts @@ -18,6 +18,9 @@ import { getSiteSettingsController } from "../interface-adapters/controllers/get import { getPageBySlugController } from "../interface-adapters/controllers/get-page-by-slug.controller"; import { userSignedUpEvent } from "@repo/auth"; import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler"; +import { sendWelcomeEmailJob } from "../jobs/send-welcome-email.job"; +import { RecordingMailerService } from "../infrastructure/services/recording-mailer.service"; +import type { IMailerService } from "../application/services/mailer.service.interface"; export function bindProductionMarketingPages( config: SanitizedConfig, @@ -119,10 +122,17 @@ export function bindProductionMarketingPages( ), ), ); - // bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator - // output at the / anchors below. - void bus; - void queue; + // Bind the production mailer. Until a real SMTP/SES adapter ships, this is + // a placeholder RecordingMailerService — production wiring is structurally + // identical to dev-seed but the impl is the seam where a real mailer plugs in. + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IMailerService)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IMailerService); + } + const mailer = new RecordingMailerService(); + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.IMailerService) + .toConstantValue(mailer); + // // onAuthUserSignedUpHandler subscription — generated, edit the handler file (not this block) for behavior. const wrappedAuthUserSignedUp = withSpan( @@ -135,7 +145,7 @@ export function bindProductionMarketingPages( layer: "event-handler", name: "marketing-pages.onAuthUserSignedUpHandler", }, - onAuthUserSignedUpHandler(), + onAuthUserSignedUpHandler(queue), ), ); if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IOnAuthUserSignedUpHandler)) { @@ -144,4 +154,21 @@ export function bindProductionMarketingPages( marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.IOnAuthUserSignedUpHandler).toConstantValue(wrappedAuthUserSignedUp); bus.subscribe(userSignedUpEvent, "marketing-pages", wrappedAuthUserSignedUp); // + const wrappedSendWelcomeEmail = withSpan( + tracer, + { name: "marketing-pages.sendWelcomeEmail", op: "job" }, + withCapture( + logger, + { + feature: "marketing-pages", + layer: "job", + name: "marketing-pages.sendWelcomeEmail", + }, + sendWelcomeEmailJob(mailer), + ), + ); + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob); + } + marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob).toConstantValue(wrappedSendWelcomeEmail); } diff --git a/packages/marketing-pages/src/di/symbols.ts b/packages/marketing-pages/src/di/symbols.ts index 795a6d2..b230fdf 100644 --- a/packages/marketing-pages/src/di/symbols.ts +++ b/packages/marketing-pages/src/di/symbols.ts @@ -11,4 +11,5 @@ export const MARKETING_PAGES_SYMBOLS = { // IOnAuthUserSignedUpHandler: Symbol.for("@repo/marketing-pages/onAuthUserSignedUp"), // + ISendWelcomeEmailJob: Symbol.for("@repo/marketing-pages/sendWelcomeEmailJob"), } as const; diff --git a/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.test.ts b/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.test.ts index 1eab3d2..fcba6ec 100644 --- a/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.test.ts +++ b/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.test.ts @@ -1,15 +1,25 @@ // packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.test.ts import { describe, it, expect } from "vitest"; +import { RecordingJobQueue } from "@repo/core-testing/instrumentation"; import { onAuthUserSignedUpHandler } from "@/events/handlers/on-auth-user-signed-up.handler"; describe("onAuthUserSignedUpHandler", () => { - it("returns a function (factory shape)", () => { - const handler = onAuthUserSignedUpHandler(); - expect(typeof handler).toBe("function"); - }); + it("enqueues marketing-pages.send-welcome-email with userId + email", async () => { + const queue = new RecordingJobQueue(); + const handler = onAuthUserSignedUpHandler(queue); - it("does not throw on a valid stub event", async () => { - const handler = onAuthUserSignedUpHandler(); - await expect(handler({} as never)).resolves.toBeUndefined(); + await handler({ + userId: "u-42", + email: "alice@example.com", + signedUpAt: "2026-05-08T12:00:00.000Z", + }); + + expect(queue.enqueued).toHaveLength(1); + expect(queue.enqueued[0]).toEqual( + expect.objectContaining({ + taskSlug: "marketing-pages.send-welcome-email", + input: { userId: "u-42", email: "alice@example.com" }, + }), + ); }); }); diff --git a/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts b/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts index ec16c5f..d161721 100644 --- a/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts +++ b/packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts @@ -1,14 +1,16 @@ // packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts import type { UserSignedUpEvent } from "@repo/auth"; +import type { IJobQueue } from "@repo/core-shared/jobs"; export type IOnAuthUserSignedUpHandler = ReturnType< typeof onAuthUserSignedUpHandler >; export const onAuthUserSignedUpHandler = - () => - async (_event: UserSignedUpEvent): Promise => { - // TODO: implement the reaction. Inject dependencies via the factory's - // constructor and use them here. The handler runs inside the consumer's - // span+capture sandwich, so just throwing on failure is the right shape. + (queue: IJobQueue) => + async (event: UserSignedUpEvent): Promise => { + await queue.enqueue("marketing-pages.send-welcome-email", { + userId: event.userId, + email: event.email, + }); }; diff --git a/packages/marketing-pages/src/integrations/cms/index.ts b/packages/marketing-pages/src/integrations/cms/index.ts index 4b8b87e..c31cafa 100644 --- a/packages/marketing-pages/src/integrations/cms/index.ts +++ b/packages/marketing-pages/src/integrations/cms/index.ts @@ -1,4 +1,5 @@ export { pages } from "./collections/pages"; export { siteSettings } from "./globals/site-settings"; // +export { sendWelcomeEmailTask } from "./jobs/send-welcome-email.task"; export { onAuthUserSignedUpEventTask } from "./jobs/__events-auth-user-signed-up.task"; diff --git a/packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts b/packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts new file mode 100644 index 0000000..bba405d --- /dev/null +++ b/packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts @@ -0,0 +1,18 @@ +// packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts +import type { TaskConfig } from "payload"; +import { marketingPagesContainer } from "../../../di/container"; +import { MARKETING_PAGES_SYMBOLS } from "../../../di/symbols"; +import type { ISendWelcomeEmailJob } from "../../../jobs/send-welcome-email.job"; + +export const sendWelcomeEmailTask: TaskConfig<"marketing-pages.send-welcome-email"> = { + slug: "marketing-pages.send-welcome-email", + inputSchema: [], + retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } }, + handler: async ({ input }) => { + const job = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, + ); + await job(input as never); + return { output: {} }; + }, +}; diff --git a/packages/marketing-pages/src/jobs/send-welcome-email.job.test.ts b/packages/marketing-pages/src/jobs/send-welcome-email.job.test.ts new file mode 100644 index 0000000..32ddbb2 --- /dev/null +++ b/packages/marketing-pages/src/jobs/send-welcome-email.job.test.ts @@ -0,0 +1,37 @@ +// packages/marketing-pages/src/jobs/send-welcome-email.job.test.ts +import { describe, it, expect } from "vitest"; +import { sendWelcomeEmailJob, sendWelcomeEmailInputSchema } from "@/jobs/send-welcome-email.job"; +import { RecordingMailerService } from "@/infrastructure/services/recording-mailer.service"; + +describe("sendWelcomeEmailJob", () => { + it("rejects unknown input fields", async () => { + const mailer = new RecordingMailerService(); + const job = sendWelcomeEmailJob(mailer); + await expect(job({ unexpectedField: 1 } as never)).rejects.toThrow(); + }); + + it("rejects input missing required fields", async () => { + const mailer = new RecordingMailerService(); + const job = sendWelcomeEmailJob(mailer); + await expect(job({ userId: "u1" } as never)).rejects.toThrow(); + }); + + it("rejects invalid email", async () => { + const mailer = new RecordingMailerService(); + const job = sendWelcomeEmailJob(mailer); + await expect( + job({ userId: "u1", email: "not-an-email" }), + ).rejects.toThrow(); + }); + + it("sends a welcome email via the mailer on valid input", async () => { + const mailer = new RecordingMailerService(); + const job = sendWelcomeEmailJob(mailer); + await job({ userId: "u1", email: "u1@example.com" }); + expect(mailer.sent).toEqual([{ userId: "u1", email: "u1@example.com" }]); + }); + + it("references its input schema", () => { + expect(sendWelcomeEmailInputSchema).toBeDefined(); + }); +}); diff --git a/packages/marketing-pages/src/jobs/send-welcome-email.job.ts b/packages/marketing-pages/src/jobs/send-welcome-email.job.ts new file mode 100644 index 0000000..cbf4e66 --- /dev/null +++ b/packages/marketing-pages/src/jobs/send-welcome-email.job.ts @@ -0,0 +1,20 @@ +// packages/marketing-pages/src/jobs/send-welcome-email.job.ts +import { z } from "zod"; +import type { IMailerService } from "../application/services/mailer.service.interface"; + +export const sendWelcomeEmailInputSchema = z + .object({ + userId: z.string(), + email: z.string().email(), + }) + .strict(); + +export type SendWelcomeEmailInput = z.infer; +export type ISendWelcomeEmailJob = ReturnType; + +export const sendWelcomeEmailJob = + (mailer: IMailerService) => + async (input: SendWelcomeEmailInput): Promise => { + sendWelcomeEmailInputSchema.parse(input); + await mailer.sendWelcome(input.userId, input.email); + };