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:
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,4 +26,5 @@ export const QUEUE_NAMES = {
|
||||
QUERY_CLEANUP: "query-cleanup",
|
||||
CATALOG_PREFETCH: "catalog-prefetch",
|
||||
TRANSLATION: "translation",
|
||||
LIFECYCLE_EMAIL: "lifecycle-email",
|
||||
} as const;
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
127
apps/api/src/jobs/processors/lifecycle-email.processor.ts
Normal file
127
apps/api/src/jobs/processors/lifecycle-email.processor.ts
Normal 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 3–4 days out (→ "3 days left").
|
||||
* • win-back — users whose access ended 7–8 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;
|
||||
}
|
||||
22
apps/api/src/jobs/queues/lifecycle-email.queue.ts
Normal file
22
apps/api/src/jobs/queues/lifecycle-email.queue.ts
Normal 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 },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
14
apps/api/src/notifications/notifications.module.ts
Normal file
14
apps/api/src/notifications/notifications.module.ts
Normal 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 {}
|
||||
146
apps/api/src/notifications/novu.service.ts
Normal file
146
apps/api/src/notifications/novu.service.ts
Normal 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"),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
111
apps/api/src/notifications/novu.ts
Normal file
111
apps/api/src/notifications/novu.ts
Normal 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;
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user