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

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