feat(api): wire Novu lifecycle email triggers
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:
2026-05-27 01:30:39 +03:00
parent d35a009653
commit 1c79c18aff
18 changed files with 665 additions and 8 deletions

View File

@@ -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),
});
}

View File

@@ -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);
},
},
},
},