feat(api): wire Novu lifecycle email triggers
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Route all lifecycle/transactional emails through Novu (api.bildirim.semih.ai, delivered via Postal). A framework-agnostic client is shared by the NestJS API and the standalone BullMQ worker. - welcome + referral on signup (better-auth user.create.after) - email-verification + password-reset (auth.ts; token links never track-wrapped so the one-time token survives) - referral-qualified / referral-reward to the referrer on qualification - payment-success / payment-failed in the Stripe webhook handlers - trial-ending + win-back via a new daily lifecycle-email cron (worker), idempotent via a 1-day endDate window (no sent-flag column) - signed track.sase.tr CTA links when MAILTRACK_SECRET is set - NOVU_* / APP_PUBLIC_URL / MAILTRACK_SECRET env added to config, validation, .env.example and both compose service blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Module, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { NovuService } from "../notifications/novu.service";
|
||||
import { ReferralsModule } from "../referrals/referrals.module";
|
||||
import { ReferralsService } from "../referrals/referrals.service";
|
||||
import { createAuth } from "./auth";
|
||||
@@ -17,6 +18,7 @@ export class AuthModule implements OnModuleInit {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private emailService: EmailService,
|
||||
private novuService: NovuService,
|
||||
private referralsService: ReferralsService,
|
||||
) {}
|
||||
|
||||
@@ -32,6 +34,7 @@ export class AuthModule implements OnModuleInit {
|
||||
createAuth(databaseUrl, secret, baseUrl, {
|
||||
social: { googleClientId, googleClientSecret },
|
||||
emailService: this.emailService,
|
||||
novu: this.novuService,
|
||||
onEmailVerified: (userId) => this.referralsService.qualifyReferral(userId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../database/schema/core";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import type { NovuService } from "../notifications/novu.service";
|
||||
|
||||
/**
|
||||
* Generates a referral code that doesn't collide with an existing one. The code
|
||||
@@ -39,6 +40,8 @@ interface SocialCredentials {
|
||||
interface AuthOptions {
|
||||
social?: SocialCredentials;
|
||||
emailService?: EmailService;
|
||||
/** Lifecycle/transactional e-mail via Novu. Preferred over emailService. */
|
||||
novu?: NovuService;
|
||||
/** Called after a user's email is verified (referral qualification, etc.). */
|
||||
onEmailVerified?: (userId: string) => Promise<void>;
|
||||
}
|
||||
@@ -71,7 +74,12 @@ export function createAuth(
|
||||
enabled: true,
|
||||
minPasswordLength: 8,
|
||||
sendResetPassword: async (data) => {
|
||||
if (options?.emailService) {
|
||||
if (options?.novu) {
|
||||
await options.novu.passwordReset(
|
||||
{ id: data.user.id, email: data.user.email, name: data.user.name },
|
||||
data.url,
|
||||
);
|
||||
} else if (options?.emailService) {
|
||||
await options.emailService.sendPasswordReset(data.user.email, data.url);
|
||||
} else {
|
||||
console.log(`[DEV] Password reset URL for ${data.user.email}: ${data.url}`);
|
||||
@@ -92,7 +100,12 @@ export function createAuth(
|
||||
// Non-absolute URL (shouldn't happen) — fall back to the original.
|
||||
}
|
||||
|
||||
if (options?.emailService) {
|
||||
if (options?.novu) {
|
||||
await options.novu.emailVerification(
|
||||
{ id: data.user.id, email: data.user.email, name: data.user.name },
|
||||
verificationUrl,
|
||||
);
|
||||
} else if (options?.emailService) {
|
||||
await options.emailService.sendEmailVerification(data.user.email, verificationUrl);
|
||||
} else {
|
||||
console.log(`[DEV] Verification URL for ${data.user.email}: ${verificationUrl}`);
|
||||
@@ -132,6 +145,22 @@ export function createAuth(
|
||||
},
|
||||
};
|
||||
},
|
||||
after: async (user) => {
|
||||
// Fire-and-forget lifecycle e-mails on signup (covers password +
|
||||
// OAuth). Never await / never let a notification failure surface
|
||||
// into the signup response. `referral` is delay-stepped in Novu
|
||||
// (sent 3 days later); `welcome` goes out immediately.
|
||||
if (!options?.novu) return;
|
||||
const nu = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
const referralCode =
|
||||
typeof user.referralCode === "string" ? user.referralCode : undefined;
|
||||
void options.novu.welcome(nu);
|
||||
void options.novu.referralInvite(nu, referralCode);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user