Merge pull request 'dev' (#57) from dev into main

Reviewed-on: #57
This commit was merged in pull request #57.
This commit is contained in:
2026-05-26 22:38:22 +00:00
20 changed files with 667 additions and 10 deletions

View File

@@ -14,3 +14,12 @@ MINIO_PUBLIC_URL=https://storage.sase.tr/sase-schemas
MINIO_USE_SSL=false
CORS_ORIGIN=http://localhost:3000,https://v2.sase.tr
ML_PREDICTION_ENABLED=false
# Novu — lifecycle/transactional email automation (Tailscale-only; sends via Postal).
# Leave NOVU_API_KEY empty in dev → triggers are logged & skipped. Prod key: Bitwarden "Novu admin (bildirim.semih.ai)" → PROD_API_KEY
NOVU_API_URL=https://api.bildirim.semih.ai
NOVU_API_KEY=
# Public marketing-site origin for CTA targets
APP_PUBLIC_URL=https://sase.tr
# HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking.
MAILTRACK_SECRET=

View File

@@ -29,6 +29,7 @@ import { HealthController } from "./health.controller";
import { EmexModule } from "./integrations/emex/emex.module";
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module";
@@ -71,6 +72,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
AuthModule,
UsersModule,
EmailModule,
NotificationsModule,
StorageModule,
BrandsModule,
PlansModule,

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

View File

@@ -56,6 +56,16 @@ export default () => ({
fromAddress: process.env.POSTAL_FROM_ADDRESS || "noreply@sase.tr",
fromName: process.env.POSTAL_FROM_NAME || "Sase.tr",
},
novu: {
apiUrl: process.env.NOVU_API_URL || "https://api.bildirim.semih.ai",
apiKey: process.env.NOVU_API_KEY,
},
app: {
publicUrl: process.env.APP_PUBLIC_URL || "https://sase.tr",
},
mailtrack: {
secret: process.env.MAILTRACK_SECRET,
},
otel: {
enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

View File

@@ -26,4 +26,5 @@ export const QUEUE_NAMES = {
QUERY_CLEANUP: "query-cleanup",
CATALOG_PREFETCH: "catalog-prefetch",
TRANSLATION: "translation",
LIFECYCLE_EMAIL: "lifecycle-email",
} as const;

View File

@@ -7,6 +7,7 @@ import {
CatalogPrefetchQueueProvider,
} from "./queues/catalog-prefetch.queue";
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
import {
SUBSCRIPTION_EXPIRY_QUEUE,
@@ -20,6 +21,7 @@ import {
SubscriptionExpiryQueueProvider,
QueryCleanupQueueProvider,
CatalogPrefetchQueueProvider,
LifecycleEmailQueueProvider,
PrefetchWorkerService,
],
exports: [
@@ -27,6 +29,7 @@ import {
SUBSCRIPTION_EXPIRY_QUEUE,
QUERY_CLEANUP_QUEUE,
CATALOG_PREFETCH_QUEUE,
LIFECYCLE_EMAIL_QUEUE,
],
})
export class JobsModule implements OnModuleInit, OnModuleDestroy {
@@ -34,6 +37,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
@Inject(SUBSCRIPTION_EXPIRY_QUEUE) private subscriptionExpiryQueue: Queue,
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
) {}
async onModuleInit() {
@@ -68,6 +72,22 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
},
);
console.log("[jobs] Registered query-cleanup cron: 0 4 * * 0");
// Lifecycle e-mails (trial-ending + win-back): every day at 9:00 AM.
// Daytime so the e-mails land at a reasonable hour for recipients.
await this.lifecycleEmailQueue.upsertJobScheduler(
"lifecycle-email-daily",
{ pattern: "0 9 * * *" },
{
name: "lifecycle-email-run",
data: {},
opts: {
removeOnComplete: { count: 30 },
removeOnFail: { count: 100 },
},
},
);
console.log("[jobs] Registered lifecycle-email cron: 0 9 * * *");
}
async onModuleDestroy() {
@@ -75,6 +95,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
this.subscriptionExpiryQueue.close(),
this.queryCleanupQueue.close(),
this.catalogPrefetchQueue.close(),
this.lifecycleEmailQueue.close(),
]);
}
}

View File

@@ -0,0 +1,127 @@
import { Job } from "bullmq";
import { and, eq, gt, gte, inArray, lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userSubscriptions, users } from "../../database/schema/core";
import { buildTrackedUrl, firstNameOf, triggerNovu, webUrl } from "../../notifications/novu";
type Database = PostgresJsDatabase<Record<string, unknown>>;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Daily lifecycle e-mail cron. Two cohorts, each defined by a 1-day endDate
* window so a daily run sends to each user exactly once without needing a
* "sent" flag column:
*
* • trial-ending — trials whose endDate is 34 days out (→ "3 days left").
* • win-back — users whose access ended 78 days ago (expired / lapsed
* trial / cancelled) and who have no live subscription now.
*
* Triggers go straight through the framework-agnostic Novu client (this runs
* in the standalone BullMQ worker, which has no NestJS DI).
*/
export async function processLifecycleEmails(
job: Job,
db: Database,
): Promise<{ trialEnding: number; winBack: number }> {
console.log(`[lifecycle-email] Processing job ${job.id}`);
const now = new Date();
const trialEnding = await sendTrialEnding(db, now);
const winBack = await sendWinBack(db, now);
console.log(`[lifecycle-email] Completed: trial-ending=${trialEnding}, win-back=${winBack}`);
return { trialEnding, winBack };
}
/** Trials ending in [now+3d, now+4d) → one "3 days left" nudge. */
async function sendTrialEnding(db: Database, now: Date): Promise<number> {
const windowStart = new Date(now.getTime() + 3 * DAY_MS);
const windowEnd = new Date(now.getTime() + 4 * DAY_MS);
const rows = await db
.select({
userId: userSubscriptions.userId,
email: users.email,
name: users.name,
})
.from(userSubscriptions)
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.where(
and(
eq(userSubscriptions.status, "trial"),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
),
);
for (const r of rows) {
await triggerNovu(
"trial-ending",
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
{
daysLeft: 3,
ctaUrl: buildTrackedUrl(
"trial-ending",
r.email,
webUrl("/dashboard/settings?tab=subscription"),
),
},
);
}
return rows.length;
}
/**
* Users whose access ended in [now-8d, now-7d) and who currently have no live
* subscription → one re-engagement nudge ~7 days after churn.
*/
async function sendWinBack(db: Database, now: Date): Promise<number> {
const windowStart = new Date(now.getTime() - 8 * DAY_MS);
const windowEnd = new Date(now.getTime() - 7 * DAY_MS);
const candidates = await db
.select({
userId: userSubscriptions.userId,
email: users.email,
name: users.name,
})
.from(userSubscriptions)
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.where(
and(
inArray(userSubscriptions.status, ["expired", "trial", "cancelled"]),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
),
);
const seen = new Set<string>();
let sent = 0;
for (const c of candidates) {
if (seen.has(c.userId)) continue;
seen.add(c.userId);
// Skip anyone who already has live access again (re-subscribed / new trial).
const [live] = await db
.select({ id: userSubscriptions.id })
.from(userSubscriptions)
.where(
and(
eq(userSubscriptions.userId, c.userId),
inArray(userSubscriptions.status, ["active", "trial"]),
gt(userSubscriptions.endDate, now),
),
)
.limit(1);
if (live) continue;
await triggerNovu(
"win-back",
{ subscriberId: c.userId, email: c.email, firstName: firstNameOf(c.name) },
{ ctaUrl: buildTrackedUrl("win-back", c.email, webUrl("/dashboard")) },
);
sent++;
}
return sent;
}

View File

@@ -0,0 +1,22 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const LIFECYCLE_EMAIL_QUEUE = "LIFECYCLE_EMAIL_QUEUE";
export const LifecycleEmailQueueProvider: Provider = {
provide: LIFECYCLE_EMAIL_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.LIFECYCLE_EMAIL, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 10000 },
removeOnComplete: { count: 60 },
removeOnFail: { count: 200 },
},
});
},
};

View File

@@ -0,0 +1,14 @@
import { Global, Module } from "@nestjs/common";
import { NovuService } from "./novu.service";
/**
* Global so any module can inject NovuService without re-importing — mirrors
* EmailModule. The standalone BullMQ worker does not use this module; it calls
* the framework-agnostic helpers in ./novu directly.
*/
@Global()
@Module({
providers: [NovuService],
exports: [NovuService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,146 @@
import { Injectable, Logger } from "@nestjs/common";
import {
type NovuRecipient,
buildTrackedUrl,
firstNameOf,
formatTrDate,
formatTryAmount,
triggerNovu,
webUrl,
} from "./novu";
/** Minimal user shape needed to address a Novu trigger. */
export interface NovuUser {
id: string;
email: string;
name?: string | null;
/** "en" → English template; anything else / undefined → Turkish (default). */
locale?: string | null;
}
/**
* High-level entry point for triggering sase.tr lifecycle e-mails through Novu.
* Injectable wrapper around the framework-agnostic client in ./novu.ts. Every
* method is fire-safe (the underlying trigger never throws) so a notification
* failure can't break the calling flow.
*
* Trigger catalogue & payload contract: postal/NOVU-INTEGRATION.md.
*/
@Injectable()
export class NovuService {
private readonly logger = new Logger(NovuService.name);
private to(user: NovuUser): NovuRecipient {
return {
subscriberId: user.id,
email: user.email,
firstName: firstNameOf(user.name),
// No locale column yet → Turkish default. Set "en" here once stored.
...(user.locale === "en" ? { locale: "en" } : {}),
};
}
private trigger(name: string, user: NovuUser, payload: Record<string, unknown> = {}) {
return triggerNovu(name, this.to(user), payload, this.logger);
}
/** Welcome email — fired when a user registers. */
async welcome(user: NovuUser): Promise<void> {
await this.trigger("welcome", user, {
ctaUrl: buildTrackedUrl("welcome", user.email, webUrl("/dashboard")),
});
}
/**
* Referral programme intro — fired at signup; Novu waits 3 days then sends.
* referralUrl is the user's own share link (passed un-tracked so it stays
* copy-paste clean); ctaUrl points to their referral dashboard (tracked).
*/
async referralInvite(user: NovuUser, referralCode?: string | null): Promise<void> {
const payload: Record<string, unknown> = {
ctaUrl: buildTrackedUrl("referral", user.email, webUrl("/dashboard/settings?tab=referral")),
};
if (referralCode) {
payload.referralUrl = webUrl(`/register?ref=${encodeURIComponent(referralCode)}`);
}
await this.trigger("referral", user, payload);
}
/**
* E-mail verification. 🔒 The token link is passed directly (NOT tracked) so
* the one-time token can't be consumed by a tracking redirect.
*/
async emailVerification(user: NovuUser, verifyUrl: string): Promise<void> {
await this.trigger("email-verification", user, { verifyUrl, expiry: "24 saat" });
}
/** Password reset. 🔒 Token link passed directly (NOT tracked). */
async passwordReset(user: NovuUser, resetUrl: string): Promise<void> {
await this.trigger("password-reset", user, { resetUrl, expiry: "1 saat" });
}
/** Sent to the REFERRER when a referral qualifies but no reward milestone hit. */
async referralQualified(
referrer: NovuUser,
opts: { referredName?: string | null; totalReferrals: number },
): Promise<void> {
await this.trigger("referral-qualified", referrer, {
...(opts.referredName ? { referredName: opts.referredName } : {}),
totalReferrals: opts.totalReferrals,
ctaUrl: buildTrackedUrl(
"referral-qualified",
referrer.email,
webUrl("/dashboard/settings?tab=referral"),
),
});
}
/** Sent to the REFERRER when a 7/14-day reward milestone is earned. */
async referralReward(
referrer: NovuUser,
opts: { earnedDays: number; totalReferrals: number },
): Promise<void> {
await this.trigger("referral-reward", referrer, {
earnedDays: opts.earnedDays,
totalReferrals: opts.totalReferrals,
ctaUrl: buildTrackedUrl(
"referral-reward",
referrer.email,
webUrl("/dashboard/settings?tab=referral"),
),
});
}
/** Payment receipt — fired when a charge succeeds. amount is in kuruş. */
async paymentSuccess(
user: NovuUser,
opts: { amountKurus: number; plan?: string | null; nextBillingDate?: Date | null },
): Promise<void> {
await this.trigger("payment-success", user, {
amount: formatTryAmount(opts.amountKurus),
...(opts.plan ? { plan: opts.plan } : {}),
...(opts.nextBillingDate ? { nextBillingDate: formatTrDate(opts.nextBillingDate) } : {}),
ctaUrl: buildTrackedUrl(
"payment-success",
user.email,
webUrl("/dashboard/settings?tab=subscription"),
),
});
}
/** Dunning notice — fired when a charge fails. amount is in kuruş. */
async paymentFailed(
user: NovuUser,
opts: { amountKurus?: number; retryDate?: Date | null },
): Promise<void> {
await this.trigger("payment-failed", user, {
...(opts.amountKurus !== undefined ? { amount: formatTryAmount(opts.amountKurus) } : {}),
...(opts.retryDate ? { retryDate: formatTrDate(opts.retryDate) } : {}),
ctaUrl: buildTrackedUrl(
"payment-failed",
user.email,
webUrl("/dashboard/settings?tab=subscription"),
),
});
}
}

View File

@@ -0,0 +1,111 @@
import { createHmac, randomUUID } from "node:crypto";
/**
* Framework-agnostic Novu client. Used both by the NestJS API (via NovuService)
* and by the standalone BullMQ worker (which has no NestJS DI), so it reads
* configuration straight from process.env and has zero NestJS dependencies.
*
* All lifecycle e-mails are triggered through Novu (https://api.bildirim.semih.ai,
* Tailscale-only) and delivered via Postal. See postal/NOVU-INTEGRATION.md.
*/
/** Recipient passed as Novu's `to`. subscriberId must be stable (we use user.id). */
export interface NovuRecipient {
subscriberId: string;
email: string;
/** First name for greeting (`Merhaba {firstName}`). */
firstName?: string;
/** "en" → English template; anything else / undefined → Turkish (default). */
locale?: string;
}
export type NovuPayload = Record<string, unknown>;
const NOVU_API_URL = (process.env.NOVU_API_URL || "https://api.bildirim.semih.ai").replace(
/\/+$/,
"",
);
const APP_PUBLIC_URL = (process.env.APP_PUBLIC_URL || "https://sase.tr").replace(/\/+$/, "");
const TRIGGER_TIMEOUT_MS = 10_000;
/** Build an absolute URL on the public marketing site (e.g. webUrl("/dashboard")). */
export function webUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${APP_PUBLIC_URL}${path.startsWith("/") ? "" : "/"}${path}`;
}
/** Turkish currency formatting from an integer kuruş amount (e.g. 29900 → "₺299,00"). */
export function formatTryAmount(kurus: number): string {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(kurus / 100);
}
/** Turkish short date (e.g. "27.05.2026"). */
export function formatTrDate(date: Date): string {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
/**
* Wrap a CTA target in a signed track.sase.tr click link for open/click tracking.
* HMAC = hex(HMAC_SHA256(MAILTRACK_SECRET, `${mid}|${target}`)). Returns the raw
* target unchanged when no secret is configured.
*
* 🔒 NEVER use this for auth links (email verification / password reset) — the
* tracking redirect can consume the one-time token. Pass those URLs directly.
*/
export function buildTrackedUrl(campaign: string, recipient: string, target: string): string {
const secret = process.env.MAILTRACK_SECRET;
if (!secret) return target;
const mid = randomUUID();
const sig = createHmac("sha256", secret).update(`${mid}|${target}`).digest("hex");
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient, u: target, s: sig });
return `https://track.sase.tr/c?${q.toString()}`;
}
/**
* Fire a Novu workflow. Never throws — failures are caught and reported via the
* optional logger so a notification hiccup can never break signup/payment flows.
* No-ops (logs only) when NOVU_API_KEY is unset, so local dev needs no tailnet.
*/
export async function triggerNovu(
name: string,
to: NovuRecipient,
payload: NovuPayload = {},
logger: Pick<Console, "log" | "warn" | "error"> = console,
): Promise<void> {
const apiKey = process.env.NOVU_API_KEY;
if (!apiKey) {
logger.log(`[novu:dev] would trigger "${name}" → ${to.email} ${JSON.stringify(payload)}`);
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRIGGER_TIMEOUT_MS);
try {
const res = await fetch(`${NOVU_API_URL}/v1/events/trigger`, {
method: "POST",
headers: { Authorization: `ApiKey ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, to, payload }),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${body.slice(0, 300)}`);
return;
}
logger.log(`[novu] triggered "${name}" → ${to.email}`);
} catch (err) {
logger.error(`[novu] trigger "${name}" error: ${(err as Error).message}`);
} finally {
clearTimeout(timeout);
}
}
/** Greeting helper: first token of a full name, undefined when empty. */
export function firstNameOf(name?: string | null): string | undefined {
const first = (name ?? "").trim().split(/\s+/)[0];
return first || undefined;
}

View File

@@ -17,7 +17,8 @@ import { DATABASE, type Database } from "../../database/database.provider";
type StripeNs = import("stripe/cjs/stripe.core").Stripe;
type StripeEvent = import("stripe/cjs/stripe.core").Stripe.Event;
type CheckoutSession = import("stripe/cjs/stripe.core").Stripe.Checkout.Session;
import { payments, plans, userSubscriptions } from "../../database/schema/core";
import { payments, plans, userSubscriptions, users } from "../../database/schema/core";
import { NovuService } from "../../notifications/novu.service";
import { PostHogService } from "../../posthog/posthog.service";
import { SubscriptionsService } from "../../subscriptions/subscriptions.service";
@@ -41,6 +42,7 @@ export class StripeService {
private configService: ConfigService,
private subscriptionsService: SubscriptionsService,
private posthog: PostHogService,
private novu: NovuService,
) {
const secretKey = this.configService.get<string>("stripe.secretKey");
this.webhookSecret = this.configService.get<string>("stripe.webhookSecret");
@@ -258,7 +260,7 @@ export class StripeService {
})
.where(eq(payments.id, paymentId));
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
this.posthog.captureForUser(payment.userId, "payment_success", {
method: "stripe",
@@ -269,9 +271,48 @@ export class StripeService {
stripe_payment_intent_id: paymentIntentId,
});
await this.notifyPaymentSuccess(payment.userId, Number(payment.amount), activated);
this.logger.log(`Subscription ${payment.subscriptionId} activated via Stripe ${session.id}`);
}
/**
* Fire the payment-success receipt e-mail (Novu). Best-effort: a notification
* failure must never roll back or block webhook acknowledgement.
*/
private async notifyPaymentSuccess(
userId: string,
amountKurus: number,
activated: { planId: string; endDate: Date | null } | undefined,
): Promise<void> {
try {
const [user] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) return;
let planName: string | null = null;
if (activated?.planId) {
const [plan] = await this.db
.select({ name: plans.name })
.from(plans)
.where(eq(plans.id, activated.planId))
.limit(1);
planName = plan?.name ?? null;
}
await this.novu.paymentSuccess(user, {
amountKurus,
plan: planName,
nextBillingDate: activated?.endDate ?? null,
});
} catch (err) {
this.logger.error(`notifyPaymentSuccess failed (user=${userId}): ${String(err)}`);
}
}
private async handleCheckoutFailed(session: CheckoutSession, reason: string) {
const paymentId = session.client_reference_id ?? session.metadata?.payment_id;
if (!paymentId) return;
@@ -306,6 +347,23 @@ export class StripeService {
subscription_id: payment.subscriptionId,
reason,
});
// Best-effort dunning e-mail (Novu). No Stripe retry date for a one-off
// checkout, so retryDate is omitted; the CTA sends them to retry manually.
try {
const [user] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, payment.userId))
.limit(1);
if (user) {
await this.novu.paymentFailed(user, { amountKurus: Number(payment.amount) });
}
} catch (err) {
this.logger.error(
`payment-failed notification failed (user=${payment.userId}): ${String(err)}`,
);
}
}
/**

View File

@@ -62,7 +62,11 @@ function makeDb(opts: DbOpts = {}) {
}
function createService(db: unknown) {
return new ReferralsService(db as never);
const novu = {
referralReward: vi.fn(async () => {}),
referralQualified: vi.fn(async () => {}),
};
return new ReferralsService(db as never, novu as never);
}
describe("ReferralsService", () => {

View File

@@ -15,6 +15,7 @@ import {
import { and, desc, eq, or, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { referrals, userSubscriptions, users } from "../database/schema/core";
import { NovuService } from "../notifications/novu.service";
/** Transaction handle type derived from the drizzle db's `transaction` callback. */
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
@@ -32,7 +33,10 @@ function maskEmail(email: string): string {
export class ReferralsService {
private readonly logger = new Logger(ReferralsService.name);
constructor(@Inject(DATABASE) private db: Database) {}
constructor(
@Inject(DATABASE) private db: Database,
private novu: NovuService,
) {}
async getStats(userId: string) {
const [user] = await this.db
@@ -173,14 +177,14 @@ export class ReferralsService {
* double-counted under concurrency. Safe to call more than once.
*/
async qualifyReferral(referredUserId: string): Promise<void> {
await this.db.transaction(async (tx) => {
const outcome = await this.db.transaction(async (tx) => {
const [referral] = await tx
.select({ id: referrals.id, referrerId: referrals.referrerId })
.from(referrals)
.where(and(eq(referrals.referredId, referredUserId), eq(referrals.status, "pending")))
.for("update")
.limit(1);
if (!referral) return; // no pending referral: none exists, or already qualified
if (!referral) return null; // no pending referral: none exists, or already qualified
// Serialise concurrent qualifications for the same referrer.
await tx
@@ -210,7 +214,56 @@ export class ReferralsService {
`Referral milestone: referrer=${referral.referrerId} count=${qualifiedCount} +${rewardDays}d`,
);
}
return { referrerId: referral.referrerId, qualifiedCount, rewardDays };
});
if (outcome) {
// Notify the REFERRER after the tx commits (never inside it). Mutually
// exclusive per the integration contract: reward email when a milestone
// fired, otherwise the plain "your invite qualified" email.
await this.notifyReferrer(outcome.referrerId, referredUserId, outcome);
}
}
/**
* Sends the referrer their post-qualification e-mail via Novu. Best-effort:
* a notification failure must never break referral qualification, so this
* swallows its own errors.
*/
private async notifyReferrer(
referrerId: string,
referredUserId: string,
outcome: { qualifiedCount: number; rewardDays: number },
): Promise<void> {
try {
const [referrer] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, referrerId))
.limit(1);
if (!referrer) return;
const [referred] = await this.db
.select({ name: users.name })
.from(users)
.where(eq(users.id, referredUserId))
.limit(1);
if (outcome.rewardDays > 0) {
await this.novu.referralReward(referrer, {
earnedDays: outcome.rewardDays,
totalReferrals: outcome.qualifiedCount,
});
} else {
await this.novu.referralQualified(referrer, {
referredName: referred?.name ?? null,
totalReferrals: outcome.qualifiedCount,
});
}
} catch (err) {
this.logger.error(`notifyReferrer failed (referrer=${referrerId}): ${String(err)}`);
}
}
/**

View File

@@ -9,6 +9,7 @@ import OpenAI from "openai";
import postgres from "postgres";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
import { processTranslation } from "./jobs/processors/translation.processor";
@@ -132,6 +133,32 @@ queryCleanupWorker.on("failed", (job, err) => {
workers.push(queryCleanupWorker);
// Lifecycle Email Worker (daily trial-ending + win-back via Novu)
const lifecycleEmailWorker = new Worker(
QUEUE_NAMES.LIFECYCLE_EMAIL,
async (job) => {
return processLifecycleEmails(job, db);
},
{
connection,
concurrency: 1,
...(telemetry ? { telemetry } : {}),
},
);
lifecycleEmailWorker.on("completed", (job) => {
console.log(`[worker] lifecycle-email job ${job.id} completed`);
});
lifecycleEmailWorker.on("failed", (job, err) => {
console.error(`[worker] lifecycle-email job ${job?.id} failed: ${err.message}`);
Sentry.captureException(err, {
tags: { queue: QUEUE_NAMES.LIFECYCLE_EMAIL, jobId: job?.id },
});
});
workers.push(lifecycleEmailWorker);
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
if (openrouterApiKey) {

View File

@@ -375,7 +375,7 @@
"method": "Method",
"status": "Status",
"downloadReceipt": "Download Receipt",
"viewReceipt": "View receipt",
"viewReceipt": "Your receipt",
"receiptUnavailable": "No receipt available for this payment.",
"filterByStatus": "Filter by Status",
"filterByMethod": "Filter by Method",

View File

@@ -375,7 +375,7 @@
"method": "Yöntem",
"status": "Durum",
"downloadReceipt": "Dekontu İndir",
"viewReceipt": "Faturayı görüntüle",
"viewReceipt": "Makbuzunuz",
"receiptUnavailable": "Bu ödeme için makbuz bulunamadı.",
"filterByStatus": "Duruma Göre Filtrele",
"filterByMethod": "Yönteme Göre Filtrele",

View File

@@ -53,6 +53,11 @@ services:
- POSTAL_API_KEY=${POSTAL_API_KEY:-}
- POSTAL_FROM_ADDRESS=${POSTAL_FROM_ADDRESS:-noreply@sase.tr}
- POSTAL_FROM_NAME=${POSTAL_FROM_NAME:-Sase.tr}
# Novu lifecycle e-mail automation (Tailscale-only; sends via Postal)
- NOVU_API_URL=${NOVU_API_URL:-https://api.bildirim.semih.ai}
- NOVU_API_KEY=${NOVU_API_KEY:-}
- APP_PUBLIC_URL=${APP_PUBLIC_URL:-https://sase.tr}
- MAILTRACK_SECRET=${MAILTRACK_SECRET:-}
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}
@@ -111,6 +116,11 @@ services:
- PCAT_PROXY_HOST=${PCAT_PROXY_HOST:-gw.dataimpulse.com}
- PCAT_PROXY_USER=${PCAT_PROXY_USER:-}
- PCAT_PROXY_PASS=${PCAT_PROXY_PASS:-}
# Novu lifecycle e-mail automation — the worker fires trial-ending + win-back
- NOVU_API_URL=${NOVU_API_URL:-https://api.bildirim.semih.ai}
- NOVU_API_KEY=${NOVU_API_KEY:-}
- APP_PUBLIC_URL=${APP_PUBLIC_URL:-https://sase.tr}
- MAILTRACK_SECRET=${MAILTRACK_SECRET:-}
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}

View File

@@ -72,6 +72,16 @@ export const envSchema = z.object({
POSTAL_FROM_ADDRESS: z.string().email().default("noreply@sase.tr"),
POSTAL_FROM_NAME: z.string().default("Sase.tr"),
// Novu — lifecycle/transactional email automation (Tailscale-only, sends via Postal).
// When NOVU_API_KEY is unset, triggers are logged and skipped (dev fallback).
NOVU_API_URL: z.string().url().default("https://api.bildirim.semih.ai"),
NOVU_API_KEY: z.string().optional(),
// Public marketing-site origin used to build CTA targets (e.g. https://sase.tr/dashboard).
APP_PUBLIC_URL: z.string().url().default("https://sase.tr"),
// HMAC secret for signed track.sase.tr click links. When unset, CTAs are passed
// un-wrapped (no click tracking) — links still work.
MAILTRACK_SECRET: z.string().optional(),
// OpenTelemetry
OTEL_ENABLED: z
.string()